From 9802a4abbcfc610ea45d0b2d59c44ca0a5b6c181 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 19 Jun 2017 11:53:15 +0200 Subject: [PATCH 01/37] refactor(controller) update Channel and App controllers --- src/controllers/App.controller.js | 8 +-- src/controllers/Channels.controller.js | 99 +++++++++++++------------- 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/src/controllers/App.controller.js b/src/controllers/App.controller.js index ed7795b..7200b9b 100644 --- a/src/controllers/App.controller.js +++ b/src/controllers/App.controller.js @@ -1,9 +1,7 @@ -class AppController { +export default class AppController { - static index (req, res) { - res.status(200).send('Hi!') + static async index (req, res) { + return res.status(200).send('Hi!') } } - -module.exports = AppController diff --git a/src/controllers/Channels.controller.js b/src/controllers/Channels.controller.js index e587047..9b94b05 100644 --- a/src/controllers/Channels.controller.js +++ b/src/controllers/Channels.controller.js @@ -1,47 +1,44 @@ +import _ from 'lodash' import filter from 'filter-object' -import { - invoke, -} from '../utils' +import { invoke } from '../utils' +import { NotFoundError, ConflictError } from '../utils/errors' +import { renderOk, renderCreated, renderDeleted } from '../utils/responses' -import { - NotFoundError, - ConflictError, -} from '../utils/errors' - -import { - renderOk, - renderCreated, - renderDeleted, -} from '../utils/responses' - -const permitted = '{type,slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,botuser,password,phoneNumber,serviceId}' +const permitted = '{type,slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,botuser,password,phoneNumber,serviceId,consumerKey,consumerSecret,accessToken,accessTokenSecret}' +const permittedUpdate = '{slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,botuser,password,phoneNumber,serviceId,consumerKey,consumerSecret,accessToken,accessTokenSecret}' export default class ChannelsController { /** * Create a new channel */ - static async createChannelByConnectorId (req, res) { + static async create (req, res) { const { connector_id } = req.params const params = filter(req.body, permitted) - const { slug } = req.body + const slug = params.slug - const connector = await models.Connector.findById(connector_id).populate('channels') + const connector = await models.Connector.findById(connector_id) + .populate('channels') - if (!connector) { throw new NotFoundError('Connector') } + if (!connector) { + throw new NotFoundError('Connector') + } - let channel = connector.channels.find(c => c.slug === slug) - if (channel) { throw new ConflictError('Channel slug is already taken') } + const channel = connector.channels.find(c => c.slug === slug) - channel = await new models.Channel({ ...params, connector: connector._id }) - channel.webhook = `${config.base_url}/webhook/${channel._id}` - connector.channels.push(channel._id) + if (channel) { + throw new ConflictError('Channel slug is already taken') + } + + channel.webhook = `${global.config.gromit_base_url}/v1/webhook/${channel._id}` + connector.channels.push(channel) await Promise.all([ connector.save(), channel.save(), ]) + await invoke(channel.type, 'onChannelCreate', [channel]) return renderCreated(res, { @@ -51,34 +48,36 @@ export default class ChannelsController { } /** - * Index bot's channels + * Index channels */ - static async getChannelsByConnectorId (req, res) { + static async index (req, res) { const { connector_id } = req.params const connector = await models.Connector.findById(connector_id) - .populate('channels') + .populate('channels') - if (!connector) { throw new NotFoundError('Connector') } - if (!connector.channels.length) { - return renderOk(res, { results: [], message: 'No channels' }) + if (!connector) { + throw new NotFoundError('Connector') } return renderOk(res, { results: connector.channels.map(c => c.serialize), - message: 'Channels successfully rendered', + message: connector.channels.length ? 'Channels successfully rendered' : 'No channels', }) } /** * Show a channel */ - static async getChannelByConnectorId (req, res) { + static async show (req, res) { const { connector_id, channel_slug } = req.params - const channel = await models.Channel.findOne({ connector: connector_id, slug: channel_slug }) + const channel = models.Channel.findOne({ slug: channel_slug, connector: connector_id }) + .populate('children') - if (!channel) { throw new NotFoundError('Channel') } + if (!channel) { + throw new NotFoundError('Channel') + } return renderOk(res, { results: channel.serialize, @@ -89,23 +88,23 @@ export default class ChannelsController { /** * Update a channel */ - static async updateChannelByConnectorId (req, res) { + static async update (req, res) { const { connector_id, channel_slug } = req.params - const oldChannel = await models.Channel.findOne({ slug: channel_slug, connector: connector_id }) - - if (!oldChannel) { throw new NotFoundError('Channel') } - - const channel = await models.Channel.findOneAndUpdate( - { slug: channel_slug, connector: connector_id }, - { $set: filter(req.body, permitted) }, + const oldChannel = await global.models.Channel.findOne({ slug: channel_slug, connector: connector_id }) + const channel = await global.models.Channel.findOneAndUpdate( + { slug: channel_slug, connector: connector._id, isActive: true }, + { $set: filter(req.body, permittedUpdate) }, { new: true } ) - if (!channel) { throw new NotFoundError('Channel') } + + if (!channel || !oldChannel) { + throw new NotFoundError('Channel') + } await invoke(channel.type, 'onChannelUpdate', [channel, oldChannel]) - renderOk(res, { + return renderOk(res, { results: channel.serialize, message: 'Channel successfully updated', }) @@ -114,17 +113,21 @@ export default class ChannelsController { /** * Delete a channel */ - static async deleteChannelByConnectorId (req, res) { + static async delete (req, res) { const { connector_id, channel_slug } = req.params const channel = await models.Channel.findOne({ connector: connector_id, slug: channel_slug }) - if (!channel) { throw new NotFoundError('Channel') } + + if (!channel) { + throw new NotFoundError('Channel') + } await Promise.all([ - channel.remove(), + channel.delete(), invoke(channel.type, 'onChannelDelete', [channel]), ]) - renderDeleted(res, 'Channel successfully deleted') + return renderDeleted(res, 'Channel successfully deleted') } + } From 1a9214446dab8398945b6648bc3bd9355b33306e Mon Sep 17 00:00:00 2001 From: jhoudan Date: Tue, 20 Jun 2017 17:57:46 +0200 Subject: [PATCH 02/37] refactor(controller) minor updates on Connector --- src/controllers/Connectors.controller.js | 53 +++++++++++------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/src/controllers/Connectors.controller.js b/src/controllers/Connectors.controller.js index df54d25..dd604ea 100644 --- a/src/controllers/Connectors.controller.js +++ b/src/controllers/Connectors.controller.js @@ -1,24 +1,18 @@ +import _ from 'lodash' import filter from 'filter-object' -import { - NotFoundError, -} from '../utils/errors' +import { NotFoundError, BadRequestError } from '../utils/errors' +import { renderOk, renderCreated, renderDeleted } from '../utils/responses' -import { - renderOk, - renderCreated, - renderDeleted, -} from '../utils/responses' - -const permittedAdd = '{url}' -const permittedUpdate = '{url}' +const permittedAdd = '{url,isTyping}' +const permittedUpdate = '{url,isTyping}' export default class ConnectorsController { /** * Create a new connector */ - static async createConnector (req, res) { + static async create (req, res) { const payload = filter(req.body, permittedAdd) const connector = await new models.Connector(payload).save() @@ -30,14 +24,16 @@ export default class ConnectorsController { } /** - * Show a connector - */ - static async getConnectorByBotId (req, res) { + * Show a connector + */ + static async show (req, res) { const { connector_id } = req.params const connector = await models.Connector.findById(connector_id) - if (!connector) { throw new NotFoundError('Connector') } + if (!connector) { + throw new NotFoundError('Connector') + } return renderOk(res, { results: connector.serialize, @@ -45,18 +41,17 @@ export default class ConnectorsController { }) } - /** - * Update a connector - */ - static async updateConnectorByBotId (req, res) { + /* + * Update a connector + */ + static async update (req, res) { const { connector_id } = req.params - const connector = await models.Connector.findOneAndUpdate({ _id: connector_id }, + const connector = await global.models.Connector.findOneAndUpdate( + { _id: connector_id }, { $set: filter(req.body, permittedUpdate) }, { new: true } ).populate('channels') - if (!connector) { throw new NotFoundError('Connector') } - return renderOk(res, { results: connector.serialize, message: 'Connector successfully updated', @@ -64,15 +59,17 @@ export default class ConnectorsController { } /** - * Delete a connector - */ - static async deleteConnectorByBotId (req, res) { + * Delete a connector + */ + static async delete (req, res) { const { connector_id } = req.params const connector = await models.Connector.findById(connector_id) - .populate('channels conversations') + .populate('channels conversations') - if (!connector) { throw new NotFoundError('Connector') } + if (!connector) { + throw new NotFoundError('Connector') + } await Promise.all([ ...connector.conversations.map(c => c.remove()), From cc79e1cbe625a387ee7e23cf1014c59b13a3c25f Mon Sep 17 00:00:00 2001 From: jhoudan Date: Tue, 20 Jun 2017 18:04:11 +0200 Subject: [PATCH 03/37] refactor(core) minor updates on Conversations --- src/controllers/Conversations.controller.js | 58 ++++++++++----------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/controllers/Conversations.controller.js b/src/controllers/Conversations.controller.js index 0122b42..aa2ea88 100644 --- a/src/controllers/Conversations.controller.js +++ b/src/controllers/Conversations.controller.js @@ -1,66 +1,66 @@ +import _ from 'lodash' + +import { Logger } from '../utils' import { renderOk, renderDeleted } from '../utils/responses' import { NotFoundError, BadRequestError } from '../utils/errors' export default class ConversationController { - /* - * Index all connector conversations - */ - static async getConversationsByConnectorId (req, res) { + static async index (req, res) { const { connector_id } = req.params - const conversations = await models.Conversation.find({ connector: connector_id }) + const conversations = models.Conversation.find({ connector: connector_id }) - renderOk(res, { + return renderOk(res, { results: conversations.map(c => c.serialize), - message: conversations.length ? 'Conversations rendered with success' : 'No conversations', + message: conversations.length ? 'Conversations successfully found' : 'No conversations', }) } - /* - * Show a conversation - */ - static async getConversationByConnectorId (req, res) { + static async show (req, res) { const { connector_id, conversation_id } = req.params - const conversation = await models.Conversation.findOne({ _id: conversation_id, connector: connector_id }).populate('participants messages') + const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector._id }) + .populate('participants messages') - if (!conversation) { throw new NotFoundError('Conversation') } + if (!conversation) { + throw new NotFoundError('Conversation') + } return renderOk(res, { results: conversation.full, - message: 'Conversation rendered with success', + message: 'Conversation successfully found', }) } - /* - * Delete a conversation - */ - static async deleteConversationByConnectorId (req, res) { + static async delete (req, res) { const { connector_id, conversation_id } = req.params - const conversation = await models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) + const conversations = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) - if (!conversation) { throw new NotFoundError('Conversation') } + if (!conversation) { + throw new NotFoundError('Conversation') + } await conversation.remove() - renderDeleted(res, 'Conversation deleted with success') + return renderDeleted(res, 'Conversation successfully deleted') } - /* * Find or create a conversation */ static async findOrCreateConversation (channelId, chatId) { - let conversation = await models.Conversation.findOne({ channel: channelId, chatId }) + let conversation = await global.models.Conversation.findOne({ channel: channelId, chatId }) .populate('channel') .populate('connector', 'url _id') .populate('participants') .exec() - if (conversation) { return conversation } + if (conversation) { + return conversation + } - const channel = await models.Channel.findById(channelId).populate('connector').exec() + const channel = await global.models.Channel.findById(channelId).populate('connector').exec() if (!channel) { throw new NotFoundError('Channel') @@ -68,15 +68,15 @@ export default class ConversationController { throw new BadRequestError('Channel is not activated') } - const { connector } = channel + const connector = channel.connector if (!connector) { - throw new NotFoundError('Connector') + throw new NotFoundError('Bot') } - conversation = await new models.Conversation({ connector: connector._id, chatId, channel: channel._id }).save() + conversation = await new global.models.Conversation({ connector: connector._id, chatId, channel: channel._id }).save() connector.conversations.push(conversation._id) - await models.Connector.update({ _id: connector._id }, { $push: { conversations: conversation._id } }) + await global.models.Connector.update({ _id: connector._id }, { $push: { conversations: conversation._id } }) conversation.connector = connector conversation.channel = channel From fc60ed2f9c2c74338e571655a92a2591a763797e Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 16:15:08 +0200 Subject: [PATCH 04/37] refactor(controllers) upate Messages controller --- src/controllers/Messages.controller.js | 107 +++++++++++++------------ 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/src/controllers/Messages.controller.js b/src/controllers/Messages.controller.js index e96cecf..97cc2c8 100644 --- a/src/controllers/Messages.controller.js +++ b/src/controllers/Messages.controller.js @@ -1,39 +1,37 @@ +import _ from 'lodash' + import Logger from '../utils/Logger' import { invoke, invokeSync } from '../utils' +import { renderCreated } from '../utils/responses' import { isValidFormatMessage } from '../utils/format' import { NotFoundError, BadRequestError, ServiceError } from '../utils/errors' -import { renderCreated } from '../utils/responses' -import _ from 'lodash' -class MessagesController { +export default class MessagesController { static async pipeMessage (id, message, options) { - return controllers.Conversations.findOrCreateConversation(id, options.chatId) - .then(conversation => controllers.Messages.parseChannelMessage(conversation, message, options)) - .then(controllers.Messages.saveMessage) - .then(controllers.Webhooks.sendMessageToBot) + return global.controllers.Conversations.findOrCreateConversation(id, options.chatId) + .then(conversation => global.controllers.Messages.updateConversationWithMessage(conversation, message, options)) + .then(global.controllers.Messages.parseChannelMessage) + .then(global.controllers.Messages.saveMessage) + .then(global.controllers.Webhooks.sendMessageToBot) } - /** - * Parse a message received - * from a channel to the BC format - */ - static parseChannelMessage (conversation, message, options) { + static parseChannelMessage ([conversation, message, options]) { return invoke(conversation.channel.type, 'parseChannelMessage', [conversation, message, options]) } - /* Save a message in db and create the participant if necessary */ static async saveMessage ([conversation, message, options]) { let participant = _.find(conversation.participants, p => p.senderId === options.senderId) + const type = conversation.channel.type if (!participant) { - participant = await new models.Participant({ senderId: options.senderId }).save() + participant = await new global.models.Participant({ senderId: options.senderId }).save() - await models.Conversation.update({ _id: conversation._id }, { $push: { participants: participant._id } }) + await global.models.Conversation.update({ _id: conversation._id }, { $push: { participants: participant._id } }) conversation.participants.push(participant) } - const newMessage = new models.Message({ + const newMessage = new global.models.Message({ participant: participant._id, conversation: conversation._id, attachment: message.attachment, @@ -45,7 +43,7 @@ class MessagesController { conversation, newMessage.save(), options, - models.Conversation.update({ _id: conversation._id }, { $push: { messages: newMessage._id } }), + global.models.Conversation.update({ _id: conversation._id }, { $push: { messages: newMessage._id } }), ]) } @@ -71,13 +69,14 @@ class MessagesController { */ static async bulkSaveMessages ([conversation, messages, opts]) { let participant = _.find(conversation.participants, p => p.isBot) + if (!participant) { - participant = await new models.Participant({ senderId: conversation.connector._id, isBot: true }).save() + participant = await new global.models.Participant({ senderId: conversation.connector._id, isBot: true }).save() conversation.participants.push(participant) } messages = await Promise.all(messages.map(attachment => { - const newMessage = new models.Message({ + const newMessage = new global.models.Message({ participant: participant._id, conversation: conversation._id, attachment, @@ -96,13 +95,20 @@ class MessagesController { /** * Format an array of messages */ - static bulkFormatMessages ([conversation, messages, options]) { + static async bulkFormatMessages ([conversation, messages, options]) { const channelType = conversation.channel.type messages = messages .filter(message => !message.attachment.only || message.attachment.only.indexOf(channelType) !== -1) - .map(message => invokeSync(channelType, 'formatMessage', [conversation, message, options])) + messages = await Promise.all(messages + .map(async (message) => { + const res = await invoke(channelType, 'formatMessage', [conversation, message, options]) + return Array.isArray(res) ? res : [res] + })) + + // flattening + messages = [].concat.apply([], messages) return Promise.resolve([conversation, messages, options]) } @@ -113,29 +119,16 @@ class MessagesController { const channelType = conversation.channel.type for (const message of messages) { - let err = null - - // Try 3 times to send the message - for (let i = 0; i < 3; i++) { - try { - await invoke(channelType, 'sendMessage', [conversation, message, opts]) - break - } catch (ex) { - // Wait 2000ms before trying to send the message again - await new Promise(resolve => setTimeout(resolve, 2000)) - err = ex - } + try { + await invoke(channelType, 'sendMessage', [conversation, message, opts]) + } catch (err) { + throw new ServiceError('Error while sending message', err) } - - if (err) { throw new ServiceError('Error while sending message', err) } } return ([conversation, messages, opts]) } - /** - * Post from a bot to a channel - */ static async postMessage (req, res) { const { connector_id, conversation_id } = req.params let { messages } = req.body @@ -150,9 +143,11 @@ class MessagesController { } } - const conversation = await models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) + const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) .populate('participants channel connector').exec() + if (!conversation) { throw new NotFoundError('Conversation') } + const participant = conversation.participants.find(p => !p.isBot) if (!participant) { throw new NotFoundError('Participant') } @@ -161,10 +156,10 @@ class MessagesController { chatId: conversation.chatId, } - await controllers.Messages.bulkCheckMessages([conversation, messages, opts]) - .then(controllers.Messages.bulkSaveMessages) - .then(controllers.Messages.bulkFormatMessages) - .then(controllers.Messages.bulkSendMessages) + await global.controllers.Messages.bulkCheckMessages([conversation, messages, opts]) + .then(global.controllers.Messages.bulkSaveMessages) + .then(global.controllers.Messages.bulkFormatMessages) + .then(global.controllers.Messages.bulkSendMessages) return renderCreated(res, { results: null, message: 'Messages successfully posted' }) } @@ -188,10 +183,8 @@ class MessagesController { } } - /** - * Post message to a bot - */ - static async postMessages (req, res) { + + static async broadcastMessage (req, res) { const { connector_id } = req.params let { messages } = req.body @@ -207,17 +200,27 @@ class MessagesController { const connector = await models.Connector.findById(connector_id).populate('conversations') - if (!connector) { throw new NotFoundError('Connector') } + if (!connector) { + throw new NotFoundError('Connector') + } for (const conversation of connector.conversations) { try { - await controllers.Messages.postToConversation(conversation, messages) + await global.controllers.Messages.postToConversation(conversation, messages) } catch (err) { Logger.error('Error while broadcasting message', err) } } - renderCreated(res, { results: null, message: 'Messages successfully posted' }) + + return renderCreated(res, { results: null, message: 'Messages successfully posted' }) } -} -module.exports = MessagesController + /* + * Helpers + */ + + static async updateConversationWithMessage (conversation, message, options) { + return invoke(conversation.channel.type, 'updateConversationWithMessage', [conversation, message, options]) + } + +} From 9a681e94cdafa232e89c808ae5a20f9a3b5ce079 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:07:07 +0200 Subject: [PATCH 05/37] refactor(controller) update Webhook controller --- src/controllers/Webhooks.controller.js | 37 ++++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/controllers/Webhooks.controller.js b/src/controllers/Webhooks.controller.js index 6fa6f11..46ed287 100644 --- a/src/controllers/Webhooks.controller.js +++ b/src/controllers/Webhooks.controller.js @@ -22,12 +22,29 @@ export default class WebhooksController { throw new BadRequestError('Type is not defined') } - channel = await invoke(channel.type, 'beforePipeline', [req, res, channel]) + invokeSync(channel.type, 'checkSecurity', [req, res, channel]) + channel = await invoke(channel.type, 'beforePipeline', [req, res, channel]) const options = invokeSync(channel.type, 'extractOptions', [req, res, channel]) - invokeSync(channel.type, 'checkSecurity', [req, res, channel]) - await controllers.Messages.pipeMessage(channel._id, req.body, options) + if (channel.connector.isTyping) { + invoke(channel.type, 'sendIsTyping', [channel, options, req.body]) + } + + const message = await invoke(channel._id, 'getRawMessage', [channel, req, options]) + + await controllers.Messages.pipeMessage(channel._id, message, options) + } + + static async subscribeWebhhok (req, res) { + const { channel_id } = req.params + const channel = await global.models.Channel.findByIf(channel_id) + + if (!channel) { + throw new NotFoundError('Channel') + } + + return invoke(channel.type, 'onWebhookChecking', [req, res, channel]) } /** @@ -59,18 +76,4 @@ export default class WebhooksController { return conversation } - // TODO Abstract it! - static async subscribeFacebookWebhook (req, res) { - const { channel_id } = req.params - - const channel = await models.Channel.findById(channel_id) - if (!channel) { throw new NotFoundError('Channel') } - - if (services.messenger.connectWebhook(req, channel)) { - res.status(200).send(req.query['hub.challenge']) - } else { - res.status(403).json({ results: null, message: 'Error while connecting the webhook' }) - } - } - } From 2dc9888299aec5d15289d0c80ae558776b000048 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:09:46 +0200 Subject: [PATCH 06/37] refactor(models) update Channel and Connector models --- src/models/Channel.model.js | 5 +++++ src/models/Connector.model.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/models/Channel.model.js b/src/models/Channel.model.js index 516b5a9..4c194d5 100644 --- a/src/models/Channel.model.js +++ b/src/models/Channel.model.js @@ -13,8 +13,13 @@ const ChannelSchema = new mongoose.Schema({ isActivated: { type: Boolean, required: true, default: true }, token: String, + clientAppId: String, clientId: String, clientSecret: String, + consumerKey: String, + consumerSecret: String, + accessToken: String, + accessTokenSecret: String, botuser: String, userName: String, password: String, diff --git a/src/models/Connector.model.js b/src/models/Connector.model.js index 610e375..a95ffe6 100644 --- a/src/models/Connector.model.js +++ b/src/models/Connector.model.js @@ -6,6 +6,7 @@ const ConnectorSchema = new mongoose.Schema({ url: { type: String, required: true }, channels: [{ type: String, ref: 'Channel' }], conversations: [{ type: String, ref: 'Conversation' }], + isTyping: { type: Boolean, required: true, default: true }, }, { usePushEach: true, timestamps: true, @@ -27,6 +28,7 @@ ConnectorSchema.virtual('serialize').get(function () { return { id: this._id, url: this.url, + isTyping: this.isTyping, conversations: this.conversations, channels: this.channels.map(c => c.serialize || c), } From 32c6534add1893aaf895b4c1104a00c1117c4d49 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:28:06 +0200 Subject: [PATCH 07/37] refactor(utils) update utils --- src/utils/utils.js | 108 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/src/utils/utils.js b/src/utils/utils.js index 416f31b..d23dfc4 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -1,29 +1,125 @@ import is from 'is_js' import md5 from 'blueimp-md5' +import { createHmac } from 'crypto' +import request from 'request' +import Twit from 'twit' +import fileType from 'file-type' +import http from 'http' +import fs from 'fs' +import request2 from 'superagent' +import tmp from 'tmp' export function getWebhookToken (id, slug) { return md5(id.toString().split('').reverse().join(''), slug) } +export function getTwitterWebhookToken (first, second) { + const hmac = createHmac('sha256', first) + hmac.update(second) + return 'sha256='.concat(hmac.digest('base64')) +} + +export function deleteTwitterWebhook (T, webhookToken) { + return new Promise((resolve, reject) => { + T._buildReqOpts('DELETE', 'account_activity/webhooks/'.concat(webhookToken), {}, false, (err, reqOpts) => { + if (err) { + reject(err) + return + } + T._doRestApiRequest(reqOpts, {}, 'DELETE', (err) => { + if (err) { + return reject(err) + } + resolve() + }) + }) + }) +} + +export function getFileType (url) { + return new Promise((resolve, reject) => { + http.get(url, res => { + res.once('data', chunk => { + res.destroy() + resolve(fileType(chunk)) + }) + res.once('error', () => { reject(new Error('could not get file type')) }) + }) + }) +} + +// We can return the media_id before we upload the file, +// but if there is an error while uploading, we can't handle it as easily +export function postMediaToTwitterFromUrl (channel, url) { + return new Promise((resolve, reject) => { + const T = new Twit({ + consumer_key: channel.consumerKey, + consumer_secret: channel.consumerSecret, + access_token: channel.accessToken, + access_token_secret: channel.accessTokenSecret, + timeout_ms: 60 * 1000, + }) + + request.head(url, (err, res) => { + if (err) { + return reject(err) + } + const length = parseInt(res.headers['content-length'], 10) + // max 15 MB imposed by twitter + if (length > 15 * 1024 * 1024) { + return reject(new Error('media too large, 15 MB limit')) + } + const parts = url.split('.') + const extension = '.'.concat(parts[parts.length - 1]) + let tmpfile = null + try { + tmpfile = tmp.fileSync({ postfix: extension }) + } catch (err2) { + return reject(err2) + } + request2.get(url).end((err, res) => { + if (err) { + return reject(err) + } + const content = res.body + try { + fs.writeFileSync(tmpfile.name, content, { encoding: 'binary' }) + } catch (err3) { + return reject(err3) + } + T.postMediaChunked({ file_path: tmpfile.name }, (err, data) => { + tmpfile.removeCallback() + if (err) { + return reject(err) + } + resolve(data.media_id_string) + }) + }) + }) + }) +} + export function noop () { return Promise.resolve() } /** - * * Invoke an async service method - * */ + * Invoke an async service method + */ export async function invoke (serviceName, methodName, args) { return global.services[serviceName][methodName](...args) } /** - * * Invoke a sync service method - * */ + * Invoke a sync service method + */ export function invokeSync (serviceName, methodName, args) { return global.services[serviceName][methodName](...args) } /** - * * Check if an url is valid - * */ + * Check if an url is valid + */ export const isInvalidUrl = url => (!url || (!is.url(url) && !(/localhost/).test(url))) + +export const arrayfy = (content) => [].concat.apply([], [content]) From 894b6319cc3de6f021e2e2758a1b1b063b1eaf81 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:29:00 +0200 Subject: [PATCH 08/37] feat(utils) add microsoft utils --- src/utils/microsoft.js | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/utils/microsoft.js diff --git a/src/utils/microsoft.js b/src/utils/microsoft.js new file mode 100644 index 0000000..30c341d --- /dev/null +++ b/src/utils/microsoft.js @@ -0,0 +1,74 @@ +import { ChatConnector, UniversalBot } from 'botbuilder' +import fileType from 'file-type' +import http from 'http' +import https from 'https' + +export function microsoftParseMessage (channel, req) { + return new Promise((resolve, reject) => { + const connector = new ChatConnector({ + appId: channel.clientId, + appPassword: channel.clientSecret, + }) + const res = { + rejectIfInvalidStatus () { + if (!this.rejected && this.stat !== undefined && (this.stat < 200 || this.stat >= 300)) { + this.rejected = true + reject(new Error('error while receiving message, status : '.concat(this.stat))) + } + }, + status (status) { + this.rejectIfInvalidStatus() + this.stat = status + this.rejectIfInvalidStatus() + }, + send (status) { + this.rejectIfInvalidStatus() + this.stat = status + this.rejectIfInvalidStatus() + }, + end () { + this.rejectIfInvalidStatus() + }, + } + connector.listen()(req, res) + const bot = new UniversalBot(connector, (session) => { + resolve({ session, message: session.message }) + }) + bot.linterPleaseLeaveMeAlone = 1 + }) +} + +export function microsoftGetBot (channel) { + const connector = new ChatConnector({ + appId: channel.clientId, + appPassword: channel.clientSecret, + }) + const bot = new UniversalBot(connector) + return bot +} + +export function getFileType (url) { + return new Promise((resolve, reject) => { + let module = http + if (url.startsWith('https')) { + module = https + } + module.get(url, res => { + res.once('data', chunk => { + res.destroy() + resolve(fileType(chunk)) + }) + res.once('error', () => { reject(new Error('could not get file type')) }) + }) + }) +} + +export async function microsoftMakeAttachement (url) { + const { mime } = await getFileType(url) + const name = url.split('/').reverse().filter(e => e.length > 0)[0] + return { + contentUrl: url, + contentType: mime, + name, + } +} From 4fa31a0e817c21a8c7e6e84219b29c061b905d02 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:35:30 +0200 Subject: [PATCH 09/37] refactor(services) minor updates --- src/services/Callr.service.js | 167 +++++++------- src/services/Kik.service.js | 250 ++++++++++++--------- src/services/Messenger.service.js | 355 +++++++++++++++++------------- src/services/Slack.service.js | 237 +++++++++++--------- src/services/SlackApp.service.js | 117 ++++++---- src/services/Telegram.service.js | 159 ++++++++----- src/services/Template.service.js | 39 +++- src/services/Twilio.service.js | 156 ++++++------- src/services/index.js | 18 +- 9 files changed, 862 insertions(+), 636 deletions(-) diff --git a/src/services/Callr.service.js b/src/services/Callr.service.js index f9e028d..f0d942a 100644 --- a/src/services/Callr.service.js +++ b/src/services/Callr.service.js @@ -1,10 +1,39 @@ +import _ from 'lodash' import callr from 'callr' import crypto from 'crypto' -import ServiceTemplate from './Template.service' +import { Logger } from '../utils' +import Template from './Template.service' import { BadRequestError, ForbiddenError } from '../utils/errors' -export default class CallrService extends ServiceTemplate { +/* + * checkParamsValidity: ok + * onChannelCreate: ok + * onChannelUpdate: ok + * onChannelDelete: ok + * onWebhookChecking: default + * checkSecurity: ok + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: default + * getParticipantInfos: default + */ + +export default class Callr extends Template { + + static checkParamsValidity (channel) { + if (!channel.password) { + throw new BadRequestError('Parameter password is missing') + } else if (!channel.userName) { + throw new BadRequestError('Parameter userName is missing') + } + } static async onChannelCreate (channel) { const type = 'sms.mo' @@ -15,71 +44,51 @@ export default class CallrService extends ServiceTemplate { await new Promise((resolve, reject) => (api.call('webhooks.subscribe', type, channel.webhook, options) .success(async (res) => { channel.webhookToken = res.hash - await channel.save() - resolve(res) + resolve() }) .error(reject))) + channel.isErrored = false } catch (err) { + Logger.info('[CallR] Error while setting webhook') channel.isErrored = true } + + return channel.save() } static async onChannelUpdate (channel, oldChannel) { - const type = 'sms.mo' - const client = new callr.api(channel.userName, channel.password) - const channelOptions = { hmac_secret: channel.password } - const oldClient = new callr.api(oldChannel.userName, oldChannel.password) + await Callr.onChannelDelete(oldChannel) + await Callr.onChannelCreate(channel) + } + static async onChannelDelete (channel) { try { - await new Promise((resolve) => oldClient.call('webhooks.unsubscribe', oldChannel.webhookToken).success(resolve()).error(resolve())) - await new Promise((resolve, reject) => client.call('webhooks.subscribe', type, channel.webhook, channelOptions) - .success(async (res) => { - channel.webhookToken = res.hash - await channel.save() - resolve(res) - }) - .error(reject)) - channel.isErrored = false + const api = new callr.api(channel.userName, channel.password) + await new Promise((resolve, reject) => (api.call('webhooks.unsubscribe', channel.webhookToken) + .success(resolve) + .error(reject))) } catch (err) { - channel.isErrored = true + Logger.info('[CallR] Error while unsetting webhook') } } - static onChannelDelete (channel) { - const api = new callr.api(channel.userName, channel.password) - - api.call('webhooks.unsubscribe', channel.webhookToken) - } - - static checkParamsValidity (channel) { - const { userName, password } = channel - - if (!password) { throw new BadRequestError('Parameter password is missing') } - if (!userName) { throw new BadRequestError('Parameter userName is missing') } - - return true - } - - static async beforePipeline (req, res, channel) { - return channel - } - static checkSecurity (req, res, channel) { const { password } = channel const payload = JSON.stringify(req.body) const webhookSig = req.headers['x-callr-hmacsignature'] const hash = crypto.createHmac('SHA256', password).update(payload).digest('base64') - if (hash !== webhookSig) { throw new ForbiddenError() } + if (hash !== webhookSig) { + throw new ForbiddenError() + } + res.status(200).send() } static extractOptions (req) { - const { body } = req - return { - chatId: body.data.to, - senderId: body.data.from, + chatId: _.get(req, 'body.data.from'), + senderId: _.get(req, 'body.data.to'), } } @@ -91,59 +100,47 @@ export default class CallrService extends ServiceTemplate { }, channelType: 'callr', } - return [conversation, msg, opts] + + return [conversation, msg, { ...opts, mentioned: true }] } - static formatMessage (conversation, message, opts) { - const reply = [] - let keyboards = null - - if (message.attachment.type === 'text' || message.attachment.type === 'picture' || message.attachment.type === 'video') { - reply.push({ type: message.attachment.type, chatId: opts.chatId, to: opts.senderId, body: message.attachment.content }) - } else if (message.attachment.type === 'quickReplies') { - const keyboards = [{ type: 'suggested' }] - keyboards[0].responses = message.attachment.content.buttons.map(button => ({ type: 'text', body: button.title })) - reply.push({ type: 'text', chatId: opts.chatId, to: opts.senderId, body: message.attachment.content.title, keyboards }) - } else if (message.attachment.type === 'card') { - if (message.attachment.content.buttons && message.attachment.content.buttons.length) { - keyboards = [{ type: 'suggested', responses: [] }] - message.attachment.content.buttons.forEach(button => { - if (button.type !== 'element_share') { keyboards[0].responses.push({ type: 'text', body: button.value }) } - }) - } - if (message.attachment.content.title) { - reply.push({ type: 'text', chatId: opts.chatId, to: opts.senderId, body: message.attachment.content.title }) - } - if (message.attachment.content.subtitle) { - reply.push({ type: 'text', chatId: opts.chatId, to: opts.senderId, body: message.attachment.content.subtitle }) - } - reply[reply.length - 1].keyboards = keyboards - } else { - throw new BadRequestError('Message type unsupported by CallR') + static formatMessage (conversation, message) { + const { type, content } = _.get(message, 'attachment', {}) + + switch (type) { + case 'text': + case 'picture': + case 'video': + return content + case 'quickReplies': + const { title, buttons } = content + return _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n`) + case 'card': { + const { title, subtitle, imageUrl, buttons } = content + return _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n${subtitle}\n${imageUrl}\n`) + } + case 'list': + return _.reduce(content.elements, (acc, elem) => { + return `${acc}\n\n- ${elem.title}\n${elem.subtitle}\n${elem.imageUrl}` + }, '') + case 'carousel': + case 'carouselle': + return _.reduce(content, (acc, card) => { + const { title, subtitle, imageUrl, buttons } = card + return acc + _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n${subtitle}\n${imageUrl}\n`) + }, '') + default: + throw new BadRequestError('Message type is non-supported by Callr') } - return reply } - static sendMessage (conversation, messages, opts) { + static sendMessage (conversation, message, opts) { return new Promise(async (resolve, reject) => { const { senderId } = opts const { chatId, channel } = conversation const api = new callr.api(channel.userName, channel.password) - const reply = messages.reduce((str, message) => { - if (message.body && message.body.length) { - str = `${str}${message.body}${'\n'}` - } - - if (message.keyboards && message.keyboards) { - const buttons = message.keyboards[0].responses - buttons.forEach((button, i) => str = `${str}${i}${' - '}${button.body}${'\n'}`) - } - - return str - }, '') - - api.call('sms.send', chatId, senderId, reply, null) + api.call('sms.send', senderId, chatId, message, null) .success(resolve) .error(reject) }) diff --git a/src/services/Kik.service.js b/src/services/Kik.service.js index 0eba2ac..419f276 100644 --- a/src/services/Kik.service.js +++ b/src/services/Kik.service.js @@ -1,18 +1,41 @@ +import _ from 'lodash' import request from 'superagent' -import ServiceTemplate from './Template.service' +import { Logger, arrayfy } from '../utils' +import Template from './Template.service' import { BadRequestError, ForbiddenError } from '../utils/errors' const agent = require('superagent-promise')(require('superagent'), Promise) -/** - * Connector's Kik Service +/* + * checkParamsValidity: ok + * onChannelCreate: ok + * onChannelUpdate: ok + * onChannelDelete: default + * onWebhookChecking: default + * checkSecurity: ok + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: ok + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: default + * getParticipantInfos: default */ -export default class KikService extends ServiceTemplate { - /* - * Subscribe webhook - */ +export default class Kik extends Template { + + static checkParamsValidity (channel) { + if (!channel.apiKey) { + throw new BadRequestError('Parameter apiKey is missing') + } else if (!channel.userName) { + throw new BadRequestError('Parameter userName is missing') + } + } + static async onChannelCreate (channel) { const data = { webhook: channel.webhook, @@ -33,147 +56,172 @@ export default class KikService extends ServiceTemplate { }) channel.isErrored = false } catch (err) { + Logger.info('[Kik] Cannot set the webhook') channel.isErrored = true } } - static async onChannelUpdate (channel) { - await KikService.onChannelCreate(channel) - } + static onChannelUpdate = Kik.onChannelCreate - /** - * Check if the message come from a valid webhook - */ static checkSecurity (req, res, channel) { - if (`${config.base_url}/webhook/${channel._id}` !== channel.webhook || req.headers['x-kik-username'] !== channel.userName) { + if (`https://${req.headers.host}/connect/v1/webhook/${channel._id}` !== channel.webhook || req.headers['x-kik-username'] !== channel.userName) { throw new ForbiddenError() } - res.status(200).send() - } - - /** - * Check if any params is missing - */ - static checkParamsValidity (channel) { - const { userName, apiKey } = channel - - if (!apiKey) { throw new BadRequestError('Parameter apiKey is missing') } - if (!userName) { throw new BadRequestError('Parameter userName is missing') } - return true + res.status(200).send() } - /** - * Extract information from the request before the pipeline - */ static extractOptions (req) { - const { body } = req - return { - chatId: body.messages[0].chatId, - senderId: body.messages[0].participants[0], + chatId: _.get(req, 'body.messages[0].chatId'), + senderId: _.get(req, 'body.messages[0].participants[0]'), } } - /** - * send 200 to kik to stop pipeline - */ - static async beforePipeline (req, res, channel) { - return channel + static async sendIsTyping (channel, options) { + const message = { + to: options.senderId, + chatId: options.chatId, + type: 'is-typing', + isTyping: true, + } + + return agent('POST', 'https://api.kik.com/v1/message') + .auth(channel.userName, channel.apiKey) + .send({ messages: [message] }) } - /** - * Parse the message to the connector format - */ static parseChannelMessage (conversation, message, opts) { - const firtsMessage = message.messages[0] - const msg = { - attachment: {}, - channelType: 'kik', - } + message = _.get(message, 'messages[0]', {}) + const msg = { attachment: {}, channelType: 'kik' } - switch (firtsMessage.type) { + switch (message.type) { case 'text': - msg.attachment = { type: 'text', content: firtsMessage.body } + msg.attachment = { type: 'text', content: message.body } break case 'link': - msg.attachment = { type: 'link', content: firtsMessage.url } + msg.attachment = { type: 'link', content: message.url } break case 'picture': - msg.attachment = { type: 'picture', content: firtsMessage.picUrl } + msg.attachment = { type: 'picture', content: message.picUrl } break case 'video': - msg.attachment = { type: 'video', content: firtsMessage.videoUrl } + msg.attachment = { type: 'video', content: message.videoUrl } break default: - throw new BadRequestError('Format not supported') + throw new BadRequestError('Message non-supported by Kik') } - return [conversation, msg, opts] + + return [conversation, msg, { ...opts, mentioned: true }] } - /** - * Parse the message to the Connector format - */ static formatMessage (conversation, message, opts) { - const reply = [] - let keyboards = null + const content = _.get(message, 'attachment.content') + const type = _.get(message, 'attachment.type') + const msg = { chatId: opts.chatId, to: opts.senderId, type } - if (message.attachment.type === 'text') { - reply.push({ type: message.attachment.type, chatId: opts.chatId, to: opts.senderId, body: message.attachment.content }) + switch (type) { + case 'text': + return { ...msg, body: content } + case 'picture': + return { ...msg, picUrl: content } + case 'video': + return { ...msg, videoUrl: content } + case 'list': { + const replies = content.elements.map(elem => { + return { + ...msg, + type: 'text', + body: `\n${elem.title}\n${elem.subtitle}\n${elem.imageUrl}`, + } + }) - } else if (message.attachment.type === 'picture') { - reply.push({ type: message.attachment.type, chatId: opts.chatId, to: opts.senderId, picUrl: message.attachment.content }) + replies[replies.length - 1].keyboards = [{ + type: 'suggested', + responses: content.buttons.map(b => ({ type: 'text', body: b.title })), + }] + return replies + } + case 'quickReplies': { + return { + ...msg, + type: 'text', + body: content.title, + keyboards: [{ + type: 'suggested', + responses: content.buttons.map(b => ({ type: 'text', body: b.title })), + }], + } + } + case 'card': { + const replies = [] + const keyboard = { type: 'suggested' } + keyboard.responses = content.buttons.map(b => ({ type: 'text', body: b.title })) + replies.push({ ...msg, type: 'text', body: content.title }) + + if (content.imageUrl) { + replies.push({ ...msg, type: 'picture', picUrl: content.imageUrl }) + } - } else if (message.attachment.type === 'video') { - reply.push({ type: message.attachment.type, chatId: opts.chatId, to: opts.senderId, videoUrl: message.attachment.content }) + replies[replies.length - 1].keyboards = [keyboard] + return replies + } + case 'carousel': + case 'carouselle': { + const replies = [] + const keyboard = { type: 'suggested' } + keyboard.responses = [].concat.apply([], content.map(c => c.buttons)).map(b => ({ type: 'text', body: b.title })) - } else if (message.attachment.type === 'quickReplies') { - keyboards = [{ type: 'suggested' }] - keyboards[0].responses = message.attachment.content.buttons.map(button => ({ type: 'text', body: button.title })) - reply.push({ type: 'text', chatId: opts.chatId, to: opts.senderId, body: message.attachment.content.title, keyboards }) + for (const card of content) { + replies.push({ ...msg, type: 'text', body: card.title }) - } else if (message.attachment.type === 'card') { - if (message.attachment.content.buttons && message.attachment.content.buttons.length) { - keyboards = [{ type: 'suggested', responses: [] }] - message.attachment.content.buttons.forEach(button => { - if (button.type !== 'element_share') { keyboards[0].responses.push({ type: 'text', body: button.value }) } - }) - } - if (message.attachment.content.title) { - reply.push({ type: 'text', chatId: opts.chatId, to: opts.senderId, body: message.attachment.content.title }) - } - if (message.attachment.content.imageUrl) { - reply.push({ type: 'picture', chatId: opts.chatId, to: opts.senderId, picUrl: message.attachment.content.imageUrl }) - } - reply[reply.length - 1].keyboards = keyboards - } else if (message.attachment.type === 'carouselle') { - if (message.attachment.content && message.attachment.content.length) { - keyboards = [{ type: 'suggested' }] - keyboards[0].responses = message.attachment.content.map(c => ({ type: 'text', body: c.buttons[0].value })) + if (card.imageUrl) { + replies.push({ ...msg, type: 'picture', picUrl: card.imageUrl }) + } } - message.attachment.content.forEach(c => { - if (c.title) { - reply.push({ type: 'text', chatId: opts.chatId, to: opts.senderId, body: c.buttons[0].title }) - } - if (c.imageUrl) { - reply.push({ type: 'picture', chatId: opts.chatId, to: opts.senderId, picUrl: c.imageUrl }) - } - }) - reply[reply.length - 1].keyboards = keyboards + replies[replies.length - 1].keyboards = [keyboard] + return replies + } + default: + throw new BadRequestError('Message type non-supported by Kik') } - - return reply } - /** - * Send the message to kik - */ static async sendMessage (conversation, messages) { - for (const message of messages) { + for (const message of arrayfy(messages)) { await agent('POST', 'https://api.kik.com/v1/message') .auth(conversation.channel.userName, conversation.channel.apiKey) .send({ messages: [message] }) } } + + static getParticipantInfos (participant, channel) { + return new Promise(async (resolve, reject) => { + request.get(`https://api.kik.com/v1/user/${participant.senderId}`) + .auth(channel.userName, channel.apiKey) + .end((err, result) => { + if (err) { + Logger.error(`Error when retrieving Kik user info: ${err}`) + return reject(err) + } + + participant.data = result.body + participant.markModified('data') + + participant.save().then(resolve).catch(reject) + }) + }) + } + + static formatParticipantData (participant) { + const informations = {} + + if (participant.data) { + const { firstName, lastName } = participant.data + informations.userName = `${firstName} ${lastName}` + } + + return informations + } } diff --git a/src/services/Messenger.service.js b/src/services/Messenger.service.js index 324a8e6..561a241 100644 --- a/src/services/Messenger.service.js +++ b/src/services/Messenger.service.js @@ -1,202 +1,243 @@ +import _ from 'lodash' +import request from 'superagent' + +import Template from './Template.service' import { getWebhookToken } from '../utils' -import { StopPipeline, BadRequestError } from '../utils/errors' -import ServiceTemplate from './Template.service' +import { StopPipeline, BadRequestError, ForbiddenError } from '../utils/errors' const agent = require('superagent-promise')(require('superagent'), Promise) -export default class MessengerService extends ServiceTemplate { +/* + * checkParamsValidity: ok + * onChannelCreate: default + * onChannelUpdate: default + * onChannelDelete: default + * onWebhookChecking: ok + * checkSecurity: default + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: ok + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: ok + * getParticipantInfos: ok + */ + +export default class Messenger extends Template { - /** - * Suscribe webhook - */ - static connectWebhook (req, channel) { - return (req.query['hub.mode'] === 'subscribe' && req.query['hub.verify_token'] === getWebhookToken(channel._id, channel.slug)) + static checkParamsValidity (channel) { + if (!channel.token) { + throw new BadRequestError('Parameter token is missing') + } else if (!channel.apiKey) { + throw new BadRequestError('Parameter apiKey is missing') + } } - /** - * Check to see if the message is form a valid webhook - */ - static checkSecurity (req, res) { - res.status(200).send() + static onWebhookChecking (req, res, channel) { + if (req.query['hub.mode'] === 'subscribe' && req.query['hub.verify_token'] === getWebhookToken(channel._id, channel.slug)) { + res.status(200).send(req.query['hub.challenge']) + } else { + throw new BadRequestError('Error while checking the webhook validity') + } } - /** - * Check to see if the message is form a valid webhook - */ - static checkParamsValidity (channel) { - const { token, apiKey } = channel - - if (!token) { throw new BadRequestError('Parameter token is missing') } - if (!apiKey) { throw new BadRequestError('Parameter apiKey is missing') } - - return true + static async checkSecurity (req, res, channel) { + if (channel.webhook.startsWith('https://'.concat(req.headers.host))) { + res.status(200).send() + } else { + throw new ForbiddenError() + } } - /** - * Extract information from the request before the pipeline - */ static extractOptions (req) { - const { body } = req + const recipientId = _.get(req, 'body.entry[0].messaging[0].recipient.id') + const senderId = _.get(req, 'body.entry[0].messaging[0].sender.id') + return { - chatId: `${body.entry[0].messaging[0].recipient.id}-${body.entry[0].messaging[0].sender.id}`, - senderId: body.entry[0].messaging[0].sender.id, + chatId: `${recipientId}-${senderId}`, + senderId, } } - /** - * Send directly a 200 to avoid the echo - */ - static async beforePipeline (req, res, channel) { - return channel - } + static sendIsTyping (channel, options, message) { + message = _.get(message, 'entry[0].messaging[0]', {}) - /** - * Parse message to connector format - */ - static async parseChannelMessage (conversation, message, opts) { - const msg = { - attachment: {}, - channelType: 'messenger', + if (!message.message || (message.is_echo && message.app_id)) { + return } - if (message.entry[0].messaging[0].account_linking) { - msg.attachment.type = 'account_linking' - msg.attachment.status = message.entry[0].messaging[0].account_linking.status + return agent('POST', `https://graph.facebook.com/v2.6/me/messages?access_token=${channel.token}`) + .send({ + recipient: { id: options.senderId }, + sender_action: 'typing_on', + }) + } - if (message.entry[0].messaging[0].account_linking.authorization_code) { - msg.attachment.content = message.entry[0].messaging[0].account_linking.authorization_code + static async parseChannelMessage (conversation, message, opts) { + const msg = {} + message = _.get(message, 'entry[0].messaging[0]') + const type = _.get(message, 'message.attachments[0].type') + const quickReply = _.get(message, 'message.quick_reply.payload') + + if (message.account_linking) { + const { status, authorization_code } = _.get(message, 'account_linking') + msg.attachment = { type: 'account_linking', status, content: authorization_code } + } else if (message.postback) { + const content = _.get(message, 'postback.payload') + msg.attachment = { type: 'payload', content } + } else if (!message.message || (message.message.is_echo && message.message.app_id)) { + throw new StopPipeline() + } else if (type) { + const content = _.get(message, 'message.attachments[0].payload.url') + msg.attachment = { + type: type === 'image' ? 'picture' : type, + content, } - - return Promise.all([conversation, msg, opts]) + } else if (quickReply) { + msg.attachment = { type: 'text', content: quickReply, is_button_click: true } + } else { + const content = _.get(message, 'message.text') + msg.attachment = { type: 'text', content } } - if (message.entry[0].messaging[0].postback) { - msg.attachment.type = 'payload' - msg.attachment.content = message.entry[0].messaging[0].postback.payload - return Promise.all([conversation, msg, opts]) + if (message.message && message.message.is_echo && !message.message.app_id) { + _.set(msg, 'attachment.isEcho', true) } - if (!message.entry[0].messaging[0].message || (message.entry[0].messaging[0].message.is_echo && message.entry[0].messaging[0].message.app_id)) { - throw new StopPipeline() - } + return Promise.all([conversation, msg, { ...opts, mentioned: true }]) + } - const facebookMessage = message.entry[0].messaging[0].message - const attachmentType = facebookMessage.attachments && facebookMessage.attachments[0].type + static formatMessage (conversation, message, opts) { + const { type, content } = _.get(message, 'attachment') + const msg = { + recipient: { id: opts.senderId }, + message: {}, + } - if (attachmentType) { - msg.attachment.type = attachmentType === 'image' ? 'picture' : attachmentType - msg.attachment.content = facebookMessage.attachments[0].payload.url - } else { - msg.attachment.type = 'text' + switch (type) { + case 'text': + _.set(msg, 'message', { text: content }) + break + case 'video': + case 'picture': + case 'audio': // Special case needed for StarWars ? + _.set(msg, 'message.attachment.type', type === 'picture' ? 'image' : type) + _.set(msg, 'message.attachment.payload.url', content) + break + case 'card': + const { title, itemUrl: item_url, imageUrl: image_url, subtitle } = _.get(message, 'attachment.content', {}) + const buttons = _.get(message, 'attachment.content.buttons', []) + .map(({ type, title, value }) => { + if (['web_url', 'account_linking'].indexOf(type) !== -1) { + return { type, title, url: value } + } else if (['postback', 'phone_number', 'element_share'].indexOf(type) !== -1) { + return { type, title, payload: value } + } + return { type } + }) - if (facebookMessage.quick_reply) { - msg.attachment.content = facebookMessage.quick_reply.payload - msg.attachment.is_button_click = true - } else { - msg.attachment.content = facebookMessage.text + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload.template_type', 'generic') + _.set(msg, 'message.attachment.payload.elements', [{ title, item_url, image_url, subtitle, buttons }]) + break + case 'quickReplies': + const text = _.get(message, 'attachment.content.title', '') + const quick_replies = _.get(message, 'attachment.content.buttons', []) + .map(b => ({ content_type: b.type || 'text', title: b.title, payload: b.value })) + + _.set(msg, 'message', { text, quick_replies }) + break + case 'list': { + const elements = _.get(message, 'attachment.content.elements', []) + .map(e => ({ + title: e.title, + image_url: e.imageUrl, + subtitle: e.subtitle, + buttons: e.buttons && e.buttons.map(b => ({ title: b.title, type: b.type, payload: b.value })), + })) + const buttons = _.get(message, 'attachment.content.buttons', []) + .map(b => ({ title: b.title, type: b.type, payload: b.value })) + + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload', { template_type: 'list', elements }) + + if (buttons.length > 0) { + _.set(msg, 'message.attachment.payload.buttons', buttons) } + break + } + case 'carousel': + case 'carouselle': + const elements = _.get(message, 'attachment.content', []) + .map(content => { + const { title, itemUrl: item_url, imageUrl: image_url, subtitle } = content + const buttons = _.get(content, 'buttons', []) + .map(({ type, title, value }) => { + if (['web_url', 'account_link'].indexOf(type) !== -1) { + return { type, title, url: value } + } + return { type, title, payload: value } + }) + const element = { title, subtitle, item_url, image_url } + + if (buttons.length > 0) { + _.set(element, 'buttons', buttons) + } + + return element + }) + + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload.template_type', 'generic') + _.set(msg, 'message.attachment.payload.elements', elements) + break + default: + throw new BadRequestError('Message type non-supported by Messenger') } - return Promise.all([conversation, msg, opts]) + return msg + } + + static async sendMessage (conversation, message) { + await agent('POST', `https://graph.facebook.com/v2.6/me/messages?access_token=${conversation.channel.token}`) + .send(message) } /* - * Parse message from bot-connector format to bot-connecto format + * Gromit methods */ - static formatMessage (conversation, message, opts) { - let msg = null - - if (message.attachment.type !== 'text' && message.attachment.type !== 'quickReplies') { - let buttons = [] - msg = { - recipient: { id: opts.senderId }, - message: { - attachment: { - type: String, - payload: {}, - }, - }, - } - if (message.attachment.type === 'picture') { - msg.message.attachment.type = 'image' - msg.message.attachment.payload.url = message.attachment.content - } else if (message.attachment.type === 'video' || message.attachment.type === 'audio') { - msg.message.attachment.type = message.attachment.type - msg.message.attachment.payload.url = message.attachment.content - } else if (message.attachment.type === 'card') { - const elements = [] - msg.message.attachment.type = 'template' - msg.message.attachment.payload.template_type = 'generic' - message.attachment.content.buttons.forEach(e => { - if (e.type === 'account_unlink') { - buttons.push({ type: e.type }) - } else if (e.type === 'web_url' || e.type === 'account_link') { - buttons.push({ type: e.type, title: e.title, url: e.value }) - } else if (e.type === 'postback' || e.type === 'phone_number' || e.type === 'element_share') { - buttons.push({ type: e.type, title: e.title, payload: e.value }) + static getParticipantInfos (participant, channel) { + return new Promise((resolve, reject) => { + const fields = 'fields=first_name,last_name,profile_pic,locale,timezone,gender' + + request.get(`https://graph.facebook.com/v2.6/${participant.senderId}?${fields}&access_token=${channel.token}`) + .end((err, result) => { + if (err) { + return reject(err) } + + participant.data = JSON.parse(result.text) + participant.markModified('data') + + return participant.save().then(resolve).catch(reject) }) - elements.push({ - title: message.attachment.content.title, - item_url: message.attachment.content.itemUrl, - image_url: message.attachment.content.imageUrl, - subtitle: message.attachment.content.subtitle, - buttons, - }) - msg.message.attachment.payload.elements = elements - } else if (message.attachment.type === 'carouselle') { - - const elements = [] - msg.message.attachment.type = 'template' - msg.message.attachment.payload.template_type = 'generic' - message.attachment.content.forEach(content => { - buttons = [] - content.buttons.forEach(e => { - if (e.type === 'web_url' || e.type === 'account_link') { - buttons.push({ type: e.type, title: e.title, url: e.value }) - } else if (e.type === 'postback' || e.type === 'phone_number' || e.type === 'element_share') { - buttons.push({ type: e.type, title: e.title, payload: e.value }) - } - }) - elements.push({ - subtitle: content.subtitle, - title: content.title, - item_url: content.itemUrl, - image_url: content.imageUrl, - buttons, - }) - }) + }) + } - msg.message.attachment.payload.elements = elements - } + static formatParticipantData (participant) { + const informations = {} - } else if (message.attachment.type === 'quickReplies') { - msg = { - recipient: { id: opts.senderId }, - message: { - text: message.attachment.content.title, - quick_replies: [], - }, - } - message.attachment.content.buttons.forEach(e => msg.message.quick_replies.push({ content_type: e.type ? e.type : 'text', title: e.title, payload: e.value })) - } else { - msg = { - recipient: { id: opts.senderId }, - message: { - text: message.attachment.content, - }, - } + if (participant.data) { + const { first_name, last_name } = participant.data + informations.userName = `${first_name} ${last_name}` } - return msg - } - /* - * Send message back to facebook - */ - static async sendMessage (conversation, message) { - await agent('POST', `https://graph.facebook.com/v2.6/me/messages?access_token=${conversation.channel.token}`) - .send(message) + return informations } } + diff --git a/src/services/Slack.service.js b/src/services/Slack.service.js index 11214f7..5acf9bb 100644 --- a/src/services/Slack.service.js +++ b/src/services/Slack.service.js @@ -1,133 +1,174 @@ +import _ from 'lodash' import request from 'superagent' -import ServiceTemplate from './Template.service' import Logger from '../utils/Logger' -import { BadRequestError, ConnectorError } from '../utils/errors' +import Template from './Template.service' +import { BadRequestError } from '../utils/errors' -export default class SlackService extends ServiceTemplate { +/* + * checkParamsValidity: ok + * onChannelCreate: default + * onChannelUpdate: default + * onChannelDelete: default + * onWebhookChecking: default + * checkSecurity: default + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: default + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: default + * getParticipantInfos: default + */ - static extractOptions (req) { - const { body } = req +export default class Slack extends Template { + static extractOptions (req) { return { - chatId: body.event.channel, - senderId: body.event.user, + chatId: _.get(req, 'body.event.channel'), + senderId: _.get(req, 'body.event.user'), } } - static checkSecurity (req, res) { - res.status(200).send() + static checkParamsValidity (channel) { + if (!channel.token) { + throw new BadRequestError('Parameter token is missing') + } } - static checkParamsValidity (channel) { - const { token } = channel + static parseChannelMessage (conversation, message, opts) { + const msg = { attachment: {} } + const file = _.get(message, 'event.file', { mimetype: '' }) - if (!token) { throw new BadRequestError('Parameter token is missing') } + opts.mentioned = _.get(message, 'event.channel', '').startsWith('D') + || _.get(message, 'event.text', '').includes(`<@${conversation.channel.botuser}>`) - return true - } + Logger.inspect(message) - /** - * Parse the message received by Slack to connector format - */ - static parseChannelMessage (conversation, message, opts) { - return new Promise((resolve, reject) => { - const parsedMessage = { - channelType: 'slack', - } - let attachment = {} - if (message.event.file) { - if (message.event.file.mimetype.startsWith('image')) { - attachment = { type: 'picture', content: message.event.file.url_private } - } else if (message.event.file.mimetype.startsWith('video')) { - attachment = { type: 'picture', content: message.event.file.url_private } - } else { - return reject(new ConnectorError('Sorry but we don\'t handle such type of file')) - } - } else { - attachment = { type: 'text', content: message.event.text } - } - parsedMessage.attachment = attachment - return resolve([conversation, parsedMessage, opts]) - }) + if (file.mimetype.startsWith('image')) { + _.set(msg, 'attachment', { type: 'picture', content: file.url_private }) + } else if (file.mimetype.startsWith('video')) { + _.set(msg, 'attachment', { type: 'picture', content: file.url_private }) + } else if (message.event && message.event.text) { + _.set(msg, 'attachment', { type: 'text', content: message.event.text.replace(`<@${conversation.channel.botuser}>`, '') }) + } else { + throw new BadRequestError('Message type non-supported by Slack') + } + + return [conversation, msg, opts] } - // Transforms a message from connector universal format to slack format static formatMessage (conversation, message) { - const { type, content } = message.attachment - let slackFormattedMessage = null + const type = _.get(message, 'attachment.type') + const content = _.get(message, 'attachment.content') + switch (type) { case 'text': - slackFormattedMessage = { text: content } - break case 'video': - slackFormattedMessage = { text: content } - break - case 'picture': - slackFormattedMessage = { text: content } - break - case 'quickReplies': - slackFormattedMessage = { - text: content.title, + case 'picture': { + return { text: content } + } + case 'list': { + return { + attachments: content.elements.map(e => ({ + color: '#3AA3E3', + title: e.title, + text: e.subtitle, + image_url: e.imageUrl, + attachment_type: 'default', + callback_id: 'callback_id', + actions: e.buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + })), + } + } + case 'quickReplies': { + const { title, buttons } = content + return { + text: title, + attachments: [{ + fallback: title, + color: '#3AA3E3', + attachemnt_type: 'default', + callback_id: 'callback_id', + actions: buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + }], + } + } + case 'card': { + return { + attachments: [{ + color: '#7CD197', + title: content.title, + text: content.subtitle, + image_url: content.imageUrl, + fallback: content.title, + attachment_type: 'default', + callback_id: 'callback_id', + actions: content.buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + }], + } + } + case 'carousel': + case 'carouselle': + return { + attachments: content.map(card => ({ + color: '#F35A00', + title: card.title, + image_url: card.imageUrl, + attachment_type: 'default', + callback_id: 'callback_id', + actions: card.buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + })), } - slackFormattedMessage.attachments = [{ - fallback: 'Sorry but I can\'t display buttons', - attachment_type: 'default', - callback_id: 'callback_id', - actions: content.buttons.map(button => { - button.name = button.title - button.text = button.title - button.type = 'button' - delete button.title - return button - }), - }] - break - case 'card': - slackFormattedMessage = {} - slackFormattedMessage.attachments = [{ - title: content.title, - text: content.subtitle, - image_url: content.imageUrl, - fallback: 'Sorry but I can\'t display buttons', - attachment_type: 'default', - callback_id: 'callback_id', - actions: content.buttons.map(button => { - button.name = button.title - button.text = button.title - button.type = 'button' - delete button.title - return button - }), - }] - break default: - throw new Error('Invalid message type') + throw new BadRequestError('Message type non-supported by Slack') } - return slackFormattedMessage } - /** - * Send a message to the Bot - */ static sendMessage (conversation, message) { return new Promise((resolve, reject) => { - const authParams = `token=${conversation.channel.token}&channel=${conversation.chatId}&as_user=true` - let params = '' + const req = request.post('https://slack.com/api/chat.postMessage') + .query({ token: conversation.channel.token, channel: conversation.chatId, as_user: true }) + if (message.text) { - params = `&text=${message.text}` + req.query({ text: message.text }) } + if (message.attachments) { - params = `${params}&attachments=${JSON.stringify(message.attachments)}` + req.query({ attachments: JSON.stringify(message.attachments) }) } - request.post(`https://slack.com/api/chat.postMessage?${authParams}${params}`) - .end((err) => { - if (err) { - Logger.error('Error while sending message to slack') - return reject(err) - } - resolve('Message sent') - }) + + req.end((err) => err ? reject(err) : resolve('Message sent')) }) } + + static getParticipantInfos (participant, channel) { + return new Promise((resolve, reject) => { + const token = channel.token + const senderId = participant.senderId + + request.get(`http://slack.com/api/users.info?token=${token}&user=${senderId}`) + .end((err, res) => { + if (err) { + Logger.error(`Error when retrieving Slack user info: ${err}`) + return reject(err) + } + + participant.data = res.body && res.body.user + participant.markModified('data') + + participant.save().then(resolve).catch(reject) + }) + }) + } + + static formatParticipantData (participant) { + return participant.data + ? { userName: participant.data.real_name } + : {} + } + } diff --git a/src/services/SlackApp.service.js b/src/services/SlackApp.service.js index de0baa9..3452681 100644 --- a/src/services/SlackApp.service.js +++ b/src/services/SlackApp.service.js @@ -1,23 +1,62 @@ import _ from 'lodash' -import request from 'superagent' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' import ServiceTemplate from './Template.service' -import Logger from '../utils/Logger' import { StopPipeline, NotFoundError, BadRequestError } from '../utils/errors' +const agent = superagentPromise(superagent, Promise) + +/* + * checkParamsValidity: ok + * onChannelCreate: ok + * onChannelUpdate: default + * onChannelDelete: ok + * onWebhookChecking: default + * checkSecurity: ok + * beforePipeline: ok + * extractOptions: default + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: default + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: default + * getParticipantInfos: default + */ + export default class SlackAppService extends ServiceTemplate { + static checkParamsValidity (channel) { + if (!channel.clientId) { + throw new BadRequestError('Parameter clientId is missing') + } else if (!channel.clientSecret) { + throw new BadRequestError('Parameter clientSecret is missing') + } + } + static onChannelCreate (channel) { - channel.oAuthUrl = `${config.base_url}/oauth/slack/${channel._id}` + channel.oAuthUrl = `${config.gromit_base_url}/v1/oauth/slack/${channel._id}` channel.save() } - static async beforePipeline (req, res, channel) { - /* Verification when filling the event subscription */ + static async onChannelDelete (channel) { + for (let child of channel.children) { + child = await models.Channel.findById(child) + if (child) { await child.remove() } + } + } + + static checkSecurity (req, res) { if (req.body && req.body.type === 'url_verification') { throw new StopPipeline(req.body.challenge) } + res.status(200).send() + } + + static async beforePipeline (req, res, channel) { /* handle action (buttons) to format them */ if (req.body.payload) { req.body = SlackAppService.parsePayload(req.body) @@ -40,14 +79,9 @@ export default class SlackAppService extends ServiceTemplate { return channel } - static checkParamsValidity (channel) { - const { clientId, clientSecret } = channel - - if (!clientId) { throw new BadRequestError('Parameter clientId is missing') } - if (!clientSecret) { throw new BadRequestError('Parameter clientSecret is missing') } - - return true - } + /* + * SlackApp specifif methods + */ static parsePayload (body) { const parsedBody = JSON.parse(body.payload) @@ -68,48 +102,37 @@ export default class SlackAppService extends ServiceTemplate { }) } - static async onChannelDelete (channel) { - for (let child of channel.children) { - child = await models.Channel.findById(child) - if (child) { await child.remove() } - } - } - static async receiveOauth (req, res) { const { channel_id } = req.params const { code } = req.query const channel = await models.Channel.findById(channel_id) if (!channel) { - Logger.info(`Received request for oauth but no channel was found for id ${channel_id}`) - res.status(404).send() - return + throw new NotFoundError('Channel not found') } - res.status(200).send() - request.post(`https://slack.com/api/oauth.access?client_id=${channel.clientId}&client_secret=${channel.clientSecret}&code=${code}`) - .end((err, res) => { - if (err || res.body.ok === false) { - Logger.error('Failed to identify to slack oauth') - } else { - const token = res.body.bot.bot_access_token - new models.Channel({ - token, - type: 'slack', - slug: res.body.team_id, - isActivated: true, - connector: channel.connector, - botuser: res.body.bot.bot_user_id, - app: channel_id, - }).save() - .then(channelChild => { - channel.children.push(channelChild._id) - channel.save() - }) - .catch(err => { - Logger.error(`An error occured while creating channel: ${err}`) - }) - } + try { + const { body } = await agent.post(`https://slack.com/api/oauth.access?client_id=${channel.clientId}&client_secret=${channel.clientSecret}&code=${code}`) + if (!body.ok) { throw new Error() } + res.status(200).send() + + const channelChild = await new models.Channel({ + type: 'slack', + app: channel_id, + slug: body.team_id, + connector: channel.connector, + botuser: body.bot.bot_user_id, + token: body.bot.bot_access_token, }) + channel.children.push(channelChild._id) + + await Promise.all([ + channelChild.save(), + channel.save(), + ]) + } catch (err) { + throw new BadRequestError('[Slack] Failed oAuth subscription') + } } + } diff --git a/src/services/Telegram.service.js b/src/services/Telegram.service.js index 7ae2679..09fb3b1 100644 --- a/src/services/Telegram.service.js +++ b/src/services/Telegram.service.js @@ -2,86 +2,103 @@ import _ from 'lodash' import superAgent from 'superagent' import superAgentPromise from 'superagent-promise' -import ServiceTemplate from './Template.service' +import { Logger } from '../utils' +import Template from './Template.service' import { ValidationError, BadRequestError } from '../utils/errors' const agent = superAgentPromise(superAgent, Promise) -export default class TelegramService extends ServiceTemplate { - - static async setWebhook (token, webhook) { - const url = `https://api.telegram.org/bot${token}/setWebhook` - const { status } = await agent.post(url, { url: webhook }) - - if (status !== 200) { - throw new BadRequestError(`[Telegram][Status ${status}] Cannot set Webhook`) - } - } - - /* Telegram token is required */ - static checkParamsValidity (req) { - if (!req.token) { +/* + * checkParamsValidity: ok + * onChannelCreate: ok + * onChannelUpdate: ok + * onChannelDelete: ok + * onWebhookChecking: default + * checkSecurity: default + * beforePipeline: ok + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: default + * getParticipantInfos: default + */ + +export default class Telegram extends Template { + + static checkParamsValidity (channel) { + if (!channel.token) { throw new ValidationError('token', 'missing') } } - /* Call when a channel is created, set webhook */ - static async onChannelCreate ({ token, webhook, ...channel }) { + static async onChannelCreate (channel) { + const { token, webhook } = channel + try { - await TelegramService.setWebhook(token, webhook) + await Telegram.setWebhook(token, webhook) + channel.isErrored = false } catch (err) { + Logger.info('[Telegram] Cannot set webhook') channel.isErrored = true } + + return channel.save() + } + + static async onChannelUpdate (channel, oldChannel) { + await Telegram.onChannelDelete(oldChannel) + await Telegram.onChannelCreate(channel) } - /* Call when a channel is updated, update webhook */ - static onChannelUpdate = TelegramService.onChannelCreate + static async onChannelDelete (channel) { + const { token } = channel - /* Call when a channel is deleted */ - static async onChannelDelete ({ token, ...channel }) { try { const { status } = await agent.get(`https://api.telegram.org/bot${token}/deleteWebhook`) if (status !== 200) { - throw new BadRequestError(`[Telegram][Status ${status}] Cannot delete Webhook`) + throw new BadRequestError(`[Telegram][Status ${status}] Cannot delete webhook`) } } catch (err) { + Logger.info('[Telegram] Cannot unset the webhook') channel.isErrored = true } } - /* Call when a message is received, before the pipeline */ - static beforePipeline (req, res, channel) { + static checkSecurity (req, res) { res.status(200).send({ status: 'success' }) - return channel } - // /* Call before entering the pipeline, to build the options object */ - static extractOptions ({ body }) { + static extractOptions (req) { return { - chatId: _.get(body, 'message.chat.id'), - senderId: _.get(body, 'message.chat.id'), + chatId: _.get(req, 'body.message.chat.id'), + senderId: _.get(req, 'body.message.chat.id'), } } - /* Call to parse a message received from a channel */ static parseChannelMessage (conversation, { message }, options) { const type = Object.keys(message).slice(-1)[0] // Get the key name of last message element const channelType = _.get(conversation, 'channel.type') const content = _.get(message, `${type}`, '') return ([ - conversation, { + conversation, + { attachment: { type, content }, channelType, - }, options, + }, { + ...options, + mentioned: true, + }, ]) } - /* Call to format a message received by the bot */ static formatMessage ({ channel, chatId }, { attachment }, { senderId }) { const { type, content } = attachment - const buttons = _.get(content, 'buttons', []) const reply = { chatId, type, @@ -90,54 +107,76 @@ export default class TelegramService extends ServiceTemplate { } switch (type) { + case 'text': + case 'video': + return { ...reply, body: content } case 'picture': return { ...reply, type: 'photo', body: content } - case 'quickReplies': case 'card': + case 'quickReplies': return { ...reply, type: 'card', photo: _.get(content, 'imageUrl'), - body: [`*${_.get(content, 'title', '')}*`] - .concat('```') - .concat(buttons.map(({ title, value }) => `${value} - ${title}`)) - .concat('```') - .join('\n'), + body: `*${_.get(content, 'title', '')}*\n**${_.get(content, 'subtitle', '')}**`, + keyboard: [_.get(content, 'buttons', []).map(b => ({ text: b.title }))], } + case 'list': + return { + ...reply, + keyboard: [_.get(content, 'buttons', []).map(b => ({ text: b.title }))], + body: content.elements.map(e => `*- ${e.title}*\n${e.subtitle}\n${e.imageUrl || ''}`), + } + case 'carousel': case 'carouselle': return { ...reply, body: content.map(({ imageUrl, buttons, title, subtitle }) => ({ - header: [`*${title}*`].concat(`[${subtitle}](${imageUrl})`).join('\n'), + header: `*${title}*\n[${subtitle || ''}](${imageUrl})`, text: ['```'].concat(buttons.map(({ title, value }) => `${value} - ${title}`)).concat('```').join('\n'), })), } default: - return { ...reply, body: content } + throw new BadRequestError('Message type non-supported by Telegram') } } - /* Call to send a message to a bot */ - static async sendMessage ({ channel }, { token, type, to, body, photo }) { + static async sendMessage ({ channel }, { token, type, to, body, photo, keyboard }) { const url = `https://api.telegram.org/bot${token}` const method = type === 'text' ? 'sendMessage' : `send${_.capitalize(type)}` - try { - if (type === 'card') { - if (!_.isUndefined(photo)) { - await agent.post(`${url}/sendPhoto`, { chat_id: to, photo }) - } - await agent.post(`${url}/sendMessage`, { chat_id: to, text: body, parse_mode: 'Markdown' }) - } else if (type === 'carouselle') { - body.forEach(async ({ header, text }) => { - await agent.post(`${url}/sendMessage`, { chat_id: to, text: header, parse_mode: 'Markdown' }) - await agent.post(`${url}/sendMessage`, { chat_id: to, text, parse_mode: 'Markdown' }) - }) - } else { - await agent.post(`${url}/${method}`, { chat_id: to, [type]: body }) + if (type === 'card') { + if (!_.isUndefined(photo)) { + await agent.post(`${url}/sendPhoto`, { chat_id: to, photo }) } - } catch (err) { - channel.isErrored = true + await agent.post(`${url}/sendMessage`, { chat_id: to, text: body, reply_markup: { keyboard, one_time_keyboard: true }, parse_mode: 'Markdown' }) + } else if (type === 'quickReplies') { + await agent.post(`${url}/sendMessage`, { chat_id: to, text: body, reply_markup: { keyboard, one_time_keyboard: true } }) + } else if (type === 'carousel' || type === 'carouselle') { + body.forEach(async ({ header, text }) => { + await agent.post(`${url}/sendMessage`, { chat_id: to, text: header, parse_mode: 'Markdown' }) + await agent.post(`${url}/sendMessage`, { chat_id: to, text, parse_mode: 'Markdown' }) + }) + } else if (type === 'list') { + for (const elem of body) { + await agent.post(`${url}/sendMessage`, { chat_id: to, text: elem, parse_mode: 'Markdown' }) + } + } else { + await agent.post(`${url}/${method}`, { chat_id: to, [type]: body }) + } + } + + /* + * Telegram specific helpers + */ + + // Set a Telegram webhook + static async setWebhook (token, webhook) { + const url = `https://api.telegram.org/bot${token}/setWebhook` + const { status } = await agent.post(url, { url: webhook }) + + if (status !== 200) { + throw new BadRequestError(`[Telegram][Status ${status}] Cannot set webhook`) } } diff --git a/src/services/Template.service.js b/src/services/Template.service.js index 88b99ee..7cbc304 100644 --- a/src/services/Template.service.js +++ b/src/services/Template.service.js @@ -1,12 +1,11 @@ import { noop } from '../utils' -export default class ServiceTemplate { +import { BadRequestError } from '../utils/errors' - /* Call when the Connector is launched */ - static onLaunch = noop +export default class ServiceTemplate { - /* Check parameter validity to create a Channel */ - static checkParamsValidity = noop + /* Check parameters validity to create a Channel */ + static checkParamsValidity = () => true /* Call when a channel is created */ static onChannelCreate = noop @@ -17,15 +16,31 @@ export default class ServiceTemplate { /* Call when a channel is deleted */ static onChannelDelete = noop + /* Check webhook validity for certain channels (Messenger) */ + static onWebhookChecking = () => { + throw new BadRequestError('Unimplemented service method') + } + /* Call when a message is received for security purpose */ - static checkSecurity = noop + static checkSecurity = (req, res) => { + res.status(200).send() + } - /* Call when a message is received, before the pipeline */ - static beforePipeline = noop + /* Perform operations before entering the pipeline */ + static beforePipeline = (req, res, channel) => channel /* Call before entering the pipeline, to build the options object */ static extractOptions = noop + /* Call to get the raw message from the received request */ + static getRawMessage = (channel, req) => req.body + + /* Call before entering the pipeline, to send a isTyping message */ + static sendIsTyping = noop + + /* Call to update a conversation based on data from the message */ + static updateConversationWithMessage = (conversation, msg, opts) => { return Promise.all([conversation, msg, opts]) } + /* Call to parse a message received from a channel */ static parseChannelMessage = noop @@ -35,4 +50,12 @@ export default class ServiceTemplate { /* Call to send a message to a bot */ static sendMessage = noop + /* + * Gromit specific methods + */ + + static formatParticipantData = () => ({}) + + static getParticipantInfos = participant => participant + } diff --git a/src/services/Twilio.service.js b/src/services/Twilio.service.js index 0d5cccb..ccb56d7 100644 --- a/src/services/Twilio.service.js +++ b/src/services/Twilio.service.js @@ -1,27 +1,46 @@ import _ from 'lodash' import crypto from 'crypto' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import Template from './Template.service' +import { BadRequestError, ForbiddenError } from '../utils/errors' + +const agent = superagentPromise(superagent, Promise) + +/* + * checkParamsValidity: ok + * onChannelCreate: default + * onChannelUpdate: default + * onChannelDelete: default + * onWebhookChecking: default + * checkSecurity: ok + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + * formatParticipantData: default + * getParticipantInfos: default + */ + +export default class Twilio extends Template { -import ServiceTemplate from './Template.service' -import { - BadRequestError, - ForbiddenError, -} from '../utils/errors' - -const agent = require('superagent-promise')(require('superagent'), Promise) - -export default class TwilioService extends ServiceTemplate { - - /* Check parameter validity to create a Channel */ static checkParamsValidity (channel) { - const { clientId, clientSecret, serviceId } = channel channel.phoneNumber = channel.phoneNumber.split(' ').join('') - if (!clientId) { throw new BadRequestError('Parameter is missing: Client Id') } - if (!clientSecret) { throw new BadRequestError('Parameter is missing: Client Secret') } - if (!serviceId) { throw new BadRequestError('Parameter is missing: Service Id') } - if (!channel.phoneNumber) { throw new BadRequestError('Parameter is missing: Phone Number') } - - return true + if (!channel.clientId) { + throw new BadRequestError('Parameter is missing: Client Id') + } else if (!channel.clientSecret) { + throw new BadRequestError('Parameter is missing: Client Secret') + } else if (!channel.serviceId) { + throw new BadRequestError('Parameter is missing: Service Id') + } else if (!channel.phoneNumber) { + throw new BadRequestError('Parameter is missing: Phone Number') + } } static checkSecurity (req, res, channel) { @@ -38,13 +57,6 @@ export default class TwilioService extends ServiceTemplate { } } - /* Call when a message is received, before the pipeline */ - static beforePipeline (req, res, channel) { - res.status(200).send() - return channel - } - - /* Call before entering the pipeline, to build the options object */ static extractOptions (req) { const { body } = req @@ -54,80 +66,72 @@ export default class TwilioService extends ServiceTemplate { } } - /* Call to parse a message received from a channel */ static parseChannelMessage (conversation, message, opts) { const msg = { attachment: { type: 'text', content: message.Body, }, - channelType: 'twilio', } - return [conversation, msg, opts] + + return [conversation, msg, { ...opts, mentioned: true }] } - /* Call to format a message received by the bot */ static formatMessage (conversation, message, opts) { const { chatId } = conversation const { type, content } = message.attachment const to = opts.senderId - - const text = () => { return content } - - const quickReplies = () => { - if (!content.title || !content.buttons) { throw new BadRequestError('Missing buttons or title for quickReplies type') } - return [`${content.title}:`] - .concat(content.buttons.map(({ title }) => { - if (!title) { throw new BadRequestError('Missing title for quickReplies type') } - return title - })).join('\r\n') + let body = '' + + switch (type) { + case 'text': + case 'picture': + case 'video': { + body = content + break } - - const card = () => { - if (!content.title || !content.buttons) { throw new BadRequestError('Missing buttons arguments or title for card type') } - return [`${content.title}:`] - .concat(content.buttons.map(({ title, value }) => { - if (!title || !value) { throw new BadRequestError('Missing title for card type') } - return `${value} - ${title}` - })).join('\r\n') + case 'list': { + return _.reduce(content.elements, (acc, elem) => { + return `${acc}\r\n${elem.title}\r\n${elem.subtitle}\r\n${elem.imageUrl}` + }, '') } - - const carouselle = () => { - const ret = [] - _.forEach(content, (card) => { - if (!card.title || !card.buttons) { throw new BadRequestError('Missing buttons arguments or title for carouselle type') } - if (card.subtitle) { card.title += `\r\n${card.subtitle}` } - ret.push([`${card.title}:`] - .concat(card.buttons.map(({ title, value }) => { - if (!title || !value) { throw new BadRequestError('Missing title for carouselle type') } - return `${value} - ${title}` - })).join('\r\n')) - }) - return ret.join('\r\n') + case 'quickReplies': { + const { title, buttons } = content + body = `${title}\r\n`.concat(buttons.map(b => b.title).join('\r\n')) + break + } + case 'card': { + const { title, subtitle, imageUrl, buttons } = content + body = _.reduce(buttons, (acc, b) => `${acc}\r\n- ${b.title}`, `${title}\r\n${subtitle}\r\n${imageUrl}`) + break + } + case 'carouselle': + case 'carousel': { + body = _.reduce(content, (acc, card) => { + const { title, subtitle, imageUrl, buttons } = card + return acc + _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n${subtitle}\n${imageUrl}\n`) + }, '') + break + } + default: + throw new BadRequestError('Message type non-supported by Twilio') } - const fns = { card, text, quickReplies, carouselle } - if (fns[type]) { return [{ chatId, to, body: fns[type](), type }] } - - throw new BadRequestError(`Message type ${type} unsupported by Twilio`) + return { chatId, to, body, type } } - /* Call to send a message to a bot */ - static async sendMessage (conversation, messages) { + static async sendMessage (conversation, message) { const data = { + To: message.to, + Body: message.body, From: conversation.channel.phoneNumber, MessagingServiceSid: conversation.channel.serviceId, - To: '', - Body: '', - } - for (const message of messages) { - data.Body = message.body - data.To = message.to - await agent('POST', `https://api.twilio.com/2010-04-01/Accounts/${conversation.channel.clientId}/Messages.json`) - .auth(conversation.channel.clientId, conversation.channel.clientSecret) - .type('form') - .send(data) } + + await agent('POST', `https://api.twilio.com/2010-04-01/Accounts/${conversation.channel.clientId}/Messages.json`) + .auth(conversation.channel.clientId, conversation.channel.clientSecret) + .type('form') + .send(data) } } diff --git a/src/services/index.js b/src/services/index.js index 4d18222..1665733 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -2,16 +2,26 @@ import Kik from './Kik.service' import Slack from './Slack.service' import SlackApp from './SlackApp.service' import Messenger from './Messenger.service' +import DirectLine from './DirectLine.service' import Callr from './Callr.service' import Twilio from './Twilio.service' import Telegram from './Telegram.service' +import CiscoSpark from './CiscoSpark.service' +import Twitter from './Twitter.service' +import Twitch from './Twitch.service' +import Microsoft from './Microsoft.service' export default { + Callr, + CiscoSpark, + DirectLine, Kik, - Slack, - SlackApp, Messenger, - Callr, - Twilio, + Microsoft, Telegram, + Twilio, + Twitch, + Twitter, + Slack, + SlackApp, } From 461d836567f9afb833ededeeeb5b6d0949a3996d Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:40:07 +0200 Subject: [PATCH 10/37] refactor(services) minor updates --- src/services/Callr.service.js | 2 -- src/services/Kik.service.js | 30 -------------------------- src/services/Messenger.service.js | 35 ------------------------------- src/services/Slack.service.js | 28 ------------------------- src/services/SlackApp.service.js | 2 -- src/services/Telegram.service.js | 2 -- src/services/Template.service.js | 8 ------- src/services/Twilio.service.js | 2 -- src/services/index.js | 4 ---- 9 files changed, 113 deletions(-) diff --git a/src/services/Callr.service.js b/src/services/Callr.service.js index f0d942a..62d9241 100644 --- a/src/services/Callr.service.js +++ b/src/services/Callr.service.js @@ -21,8 +21,6 @@ import { BadRequestError, ForbiddenError } from '../utils/errors' * parseChannelMessage: ok * formatMessage: ok * sendMessage: ok - * formatParticipantData: default - * getParticipantInfos: default */ export default class Callr extends Template { diff --git a/src/services/Kik.service.js b/src/services/Kik.service.js index 419f276..8af1358 100644 --- a/src/services/Kik.service.js +++ b/src/services/Kik.service.js @@ -22,8 +22,6 @@ const agent = require('superagent-promise')(require('superagent'), Promise) * parseChannelMessage: ok * formatMessage: ok * sendMessage: ok - * formatParticipantData: default - * getParticipantInfos: default */ export default class Kik extends Template { @@ -196,32 +194,4 @@ export default class Kik extends Template { } } - static getParticipantInfos (participant, channel) { - return new Promise(async (resolve, reject) => { - request.get(`https://api.kik.com/v1/user/${participant.senderId}`) - .auth(channel.userName, channel.apiKey) - .end((err, result) => { - if (err) { - Logger.error(`Error when retrieving Kik user info: ${err}`) - return reject(err) - } - - participant.data = result.body - participant.markModified('data') - - participant.save().then(resolve).catch(reject) - }) - }) - } - - static formatParticipantData (participant) { - const informations = {} - - if (participant.data) { - const { firstName, lastName } = participant.data - informations.userName = `${firstName} ${lastName}` - } - - return informations - } } diff --git a/src/services/Messenger.service.js b/src/services/Messenger.service.js index 561a241..50540d6 100644 --- a/src/services/Messenger.service.js +++ b/src/services/Messenger.service.js @@ -22,8 +22,6 @@ const agent = require('superagent-promise')(require('superagent'), Promise) * parseChannelMessage: ok * formatMessage: ok * sendMessage: ok - * formatParticipantData: ok - * getParticipantInfos: ok */ export default class Messenger extends Template { @@ -206,38 +204,5 @@ export default class Messenger extends Template { .send(message) } - /* - * Gromit methods - */ - - static getParticipantInfos (participant, channel) { - return new Promise((resolve, reject) => { - const fields = 'fields=first_name,last_name,profile_pic,locale,timezone,gender' - - request.get(`https://graph.facebook.com/v2.6/${participant.senderId}?${fields}&access_token=${channel.token}`) - .end((err, result) => { - if (err) { - return reject(err) - } - - participant.data = JSON.parse(result.text) - participant.markModified('data') - - return participant.save().then(resolve).catch(reject) - }) - }) - } - - static formatParticipantData (participant) { - const informations = {} - - if (participant.data) { - const { first_name, last_name } = participant.data - informations.userName = `${first_name} ${last_name}` - } - - return informations - } - } diff --git a/src/services/Slack.service.js b/src/services/Slack.service.js index 5acf9bb..59fab6f 100644 --- a/src/services/Slack.service.js +++ b/src/services/Slack.service.js @@ -20,8 +20,6 @@ import { BadRequestError } from '../utils/errors' * parseChannelMessage: default * formatMessage: ok * sendMessage: ok - * formatParticipantData: default - * getParticipantInfos: default */ export default class Slack extends Template { @@ -145,30 +143,4 @@ export default class Slack extends Template { }) } - static getParticipantInfos (participant, channel) { - return new Promise((resolve, reject) => { - const token = channel.token - const senderId = participant.senderId - - request.get(`http://slack.com/api/users.info?token=${token}&user=${senderId}`) - .end((err, res) => { - if (err) { - Logger.error(`Error when retrieving Slack user info: ${err}`) - return reject(err) - } - - participant.data = res.body && res.body.user - participant.markModified('data') - - participant.save().then(resolve).catch(reject) - }) - }) - } - - static formatParticipantData (participant) { - return participant.data - ? { userName: participant.data.real_name } - : {} - } - } diff --git a/src/services/SlackApp.service.js b/src/services/SlackApp.service.js index 3452681..898a2d0 100644 --- a/src/services/SlackApp.service.js +++ b/src/services/SlackApp.service.js @@ -22,8 +22,6 @@ const agent = superagentPromise(superagent, Promise) * parseChannelMessage: default * formatMessage: ok * sendMessage: ok - * formatParticipantData: default - * getParticipantInfos: default */ export default class SlackAppService extends ServiceTemplate { diff --git a/src/services/Telegram.service.js b/src/services/Telegram.service.js index 09fb3b1..e64890a 100644 --- a/src/services/Telegram.service.js +++ b/src/services/Telegram.service.js @@ -23,8 +23,6 @@ const agent = superAgentPromise(superAgent, Promise) * parseChannelMessage: ok * formatMessage: ok * sendMessage: ok - * formatParticipantData: default - * getParticipantInfos: default */ export default class Telegram extends Template { diff --git a/src/services/Template.service.js b/src/services/Template.service.js index 7cbc304..83747de 100644 --- a/src/services/Template.service.js +++ b/src/services/Template.service.js @@ -50,12 +50,4 @@ export default class ServiceTemplate { /* Call to send a message to a bot */ static sendMessage = noop - /* - * Gromit specific methods - */ - - static formatParticipantData = () => ({}) - - static getParticipantInfos = participant => participant - } diff --git a/src/services/Twilio.service.js b/src/services/Twilio.service.js index ccb56d7..73696a9 100644 --- a/src/services/Twilio.service.js +++ b/src/services/Twilio.service.js @@ -23,8 +23,6 @@ const agent = superagentPromise(superagent, Promise) * parseChannelMessage: ok * formatMessage: ok * sendMessage: ok - * formatParticipantData: default - * getParticipantInfos: default */ export default class Twilio extends Template { diff --git a/src/services/index.js b/src/services/index.js index 1665733..eb9acf4 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -2,25 +2,21 @@ import Kik from './Kik.service' import Slack from './Slack.service' import SlackApp from './SlackApp.service' import Messenger from './Messenger.service' -import DirectLine from './DirectLine.service' import Callr from './Callr.service' import Twilio from './Twilio.service' import Telegram from './Telegram.service' import CiscoSpark from './CiscoSpark.service' import Twitter from './Twitter.service' -import Twitch from './Twitch.service' import Microsoft from './Microsoft.service' export default { Callr, CiscoSpark, - DirectLine, Kik, Messenger, Microsoft, Telegram, Twilio, - Twitch, Twitter, Slack, SlackApp, From 7630216a70b34088e1daafc5529af43d13ebe6ce Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:40:21 +0200 Subject: [PATCH 11/37] feat(service) add CiscoSpark service --- src/services/CiscoSpark.service.js | 150 +++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 src/services/CiscoSpark.service.js diff --git a/src/services/CiscoSpark.service.js b/src/services/CiscoSpark.service.js new file mode 100644 index 0000000..ebb1389 --- /dev/null +++ b/src/services/CiscoSpark.service.js @@ -0,0 +1,150 @@ +import _ from 'lodash' +import SparkClient from 'node-sparky' + +import { Logger, arrayfy } from '../utils' +import { BadRequestError, StopPipeline } from '../utils/errors' +import Template from './Template.service' + +/* + * checkParamsValidity: ok + * onChannelCreate: ok + * onChannelUpdate: ok + * onChannelDelete: ok + * onWebhookChecking: default + * checkSecurity: default + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + */ + +export default class CiscoSpark extends Template { + + static checkParamsValidity (channel) { + if (!channel.token) { + throw new BadRequestError('Parameter token is missing') + } + } + + static async onChannelCreate (channel) { + try { + const spark = new SparkClient({ token: channel.token, webhookUrl: channel.webhook }) + const webhook = { + name: channel.slug, + targetUrl: channel.webhook, + resource: 'messages', + event: 'created', + } + + const [me] = await Promise.all([ + spark.personMe(), + spark.webhookAdd(webhook), + ]) + + channel.userName = me.id + channel.isErrored = false + } catch (err) { + Logger.info('[Cisco] Error while setting the webhook') + channel.isErrored = true + } + + return channel.save() + } + + static async onChannelUpdate (channel, oldChannel) { + await CiscoSpark.onChannelDelete(oldChannel) + await CiscoSpark.onChannelCreate(channel) + } + + static async onChannelDelete (channel) { + try { + const spark = new SparkClient({ token: channel.token }) + const webhooks = await spark.webhooksGet() + const webhook = webhooks.find(w => w.name === channel.slug) + + if (!webhook) { return } + await spark.webhookRemove(webhook.id) + } catch (err) { + Logger.info('[Cisco] Error while unsetting the webhook') + } + } + + static extractOptions (req) { + return { + chatId: _.get(req, 'body.data.roomId'), + senderId: _.get(req, 'body.data.personId'), + } + } + + static async parseChannelMessage (conversation, message, opts) { + const spark = new SparkClient({ token: conversation.channel.token }) + + message = await spark.messageGet(message.data.id) // decrypt the message + if (message.personId === conversation.channel.userName) { + throw new StopPipeline() + } + + return [ + conversation, + { attachment: { type: 'text', content: message.text } }, + { ...opts, mentioned: true }, + ] + } + + static formatMessage (conversation, message) { + const { type, content } = _.get(message, 'attachment', {}) + + switch (type) { + case 'text': + case 'video': + return { text: content } + case 'picture': + return { files: content } + case 'list': { + const payload = _.get(content, 'elements', []) + .map(e => `**- ${e.title}**\n\n${e.subtitle}\n\n${e.imageUrl || ''}`) + return { + markdown: _.reduce(payload, (acc, str) => `${acc}\n\n${str}`, ''), + } + } + case 'quickReplies': + return { + markdown: `**${_.get(content, 'title', '')}**\n\n` + .concat(_.get(content, 'buttons', []).map(b => `- ${b.title}`).join('\n\n')), + } + case 'card': + return { + files: _.get(content, 'imageUrl', ''), + markdown: `**${_.get(content, 'title', '')}**\n\n` + .concat(`*${_.get(content, 'subtitle', '')}*\n\n`) + .concat(_.get(content, 'buttons', []).map(b => `- ${b.title}`).join('\n\n')), + } + case 'carousel': + case 'carouselle': + return content.map(card => ({ + files: _.get(card, 'imageUrl', ''), + markdown: `**${_.get(card, 'title', '')}**\n\n` + .concat(`*${_.get(card, 'subtitle', '')}*\n\n`) + .concat(_.get(card, 'buttons', []).map(b => `- ${b.title}`).join('\n\n')), + })) + default: + throw new BadRequestError('Message type non-supported by CiscoSpark') + } + } + + static async sendMessage (conversation, messages, opts) { + if (conversation.channel.userName !== opts.senderId) { + const spark = new SparkClient({ token: conversation.channel.token }) + + for (const message of arrayfy(messages)) { + message.roomId = conversation.chatId + await spark.messageSend(message) + } + } + } + +} From e47a885050ec249c2ab0540e2d1f2ffad4fb9f49 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:41:02 +0200 Subject: [PATCH 12/37] feat(service) add Microsoft --- src/services/Microsoft.service.js | 171 ++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/services/Microsoft.service.js diff --git a/src/services/Microsoft.service.js b/src/services/Microsoft.service.js new file mode 100644 index 0000000..7f59a9f --- /dev/null +++ b/src/services/Microsoft.service.js @@ -0,0 +1,171 @@ +import _ from 'lodash' +import { URL } from 'url' +import { Message, HeroCard, CardAction, CardImage, ThumbnailCard, AttachmentLayout } from 'botbuilder' + +import Template from './Template.service' +import { BadRequestError, StopPipeline } from '../utils/errors' +import { Logger, microsoftParseMessage, microsoftGetBot, microsoftMakeAttachement } from '../utils' + +/* + * checkParamsValidity: ok + * onChannelCreate: default + * onChannelUpdate: default + * onChannelDelete: default + * onWebhookChecking: default + * checkSecurity: default + * beforePipeline: default + * extractOptions: ok + * getRawMessage: ok + * sendIsTyping: default + * updateConversationWithMessage: ok + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + */ + +const VIDEO_AS_LINK_HOSTS = [ + 'youtube.com', + 'youtu.be', +] + +export default class MicrosoftTemplate extends Template { + + static checkParamsValidity (channel) { + const params = ['clientId', 'clientSecret'] + params.forEach(param => { + if (!channel[param] || typeof channel[param] !== 'string') { + throw new BadRequestError('Bad parameter '.concat(param).concat(' : missing or not string')) + } + }) + } + + static async extractOptions (req, res, channel) { + const { session, message } = await microsoftParseMessage(channel, req) + + return { + session, + message, + chatId: message.address.conversation.id, + senderId: message.user.id, + } + } + + static sendIsTyping (channel, options) { + options.session.sendTyping() + } + + static getRawMessage (channel, req, options) { + return options.message + } + + static async updateConversationWithMessage (conversation, message, opts) { + conversation.microsoftAddress = message.address + conversation.markModified('microsoftAddress') + + return Promise.all([conversation.save(), message, opts]) + } + + static async parseChannelMessage (conversation, message, opts) { + const msg = {} + const attachment = _.get(message, 'attachments[0]') + if (attachment) { + if (attachment.contentType.startsWith('image')) { + msg.attachment = { type: 'picture', content: attachment.contentUrl } + } else if (attachment.contentType.startsWith('video')) { + msg.attachment = { type: 'video', content: attachment.contentUrl } + } else { + Logger.info('No support for files of type : '.concat(attachment.contentType)) + Logger.info('Defaulting to text') + if (!message.text || message.text.length <= 0) { + Logger.error('No text') + throw new StopPipeline() + } + msg.attachment = { type: 'text', content: message.text } + } + } else { + msg.attachment = { type: 'text', content: message.text } + } + return Promise.all([conversation, msg, { ...opts, mentioned: true }]) + } + + static async formatMessage (conversation, message, opts) { + const { type, content } = _.get(message, 'attachment') + const msg = new Message() + const mType = _.get(conversation, 'microsoftAddress.channelId', '') + + if (mType === 'teams') { + opts.allVideosAsLink = true + opts.allVideosAsLink = true + } + + const makeCard = (constructor, e) => { + return new constructor() + .title(e.title) + .subtitle(e.subtitle) + .images([CardImage.create(undefined, e.imageUrl)]) + .buttons(e.buttons.map(button => { + let fun = CardAction.imBack + if (['web_url', 'account_linking'].indexOf(button.type) !== -1) { + fun = CardAction.openUrl + } + return fun(undefined, button.value, button.title) + })) + } + + if (type === 'text') { + msg.text(content) + } else if (type === 'picture' || type === 'video') { + let hostname = (new URL(content)).hostname + if (hostname.startsWith('www.')) { + hostname = hostname.slice(4, hostname.length) + } + if (type === 'video' && (VIDEO_AS_LINK_HOSTS.indexOf(hostname) !== -1 || opts.allVideosAsLink)) { + msg.text(content) + } else { + const attachment = await microsoftMakeAttachement(content) + msg.addAttachment(attachment) + } + } else if (type === 'quickReplies') { + const attachment = new HeroCard() + .title(content.title) + .buttons(content.buttons.map(button => { + return CardAction.imBack(undefined, button.value, button.title) + })) + msg.addAttachment(attachment) + } else if (type === 'card') { + const attachment = makeCard(HeroCard, content) + msg.addAttachment(attachment) + } else if (type === 'list') { + const attachments = content.elements.map(e => { + return makeCard(ThumbnailCard, e) + }) + attachments.push(new ThumbnailCard() + .buttons(content.buttons.map(button => { + let fun = CardAction.imBack + if (['web_url', 'account_linking'].indexOf(button.type) !== -1) { + fun = CardAction.openUrl + } + return fun(undefined, button.value, button.title) + })) + ) + msg.attachments(attachments) + } else if (type === 'carousel' || type === 'carouselle') { + const attachments = content.map(e => { + return makeCard(HeroCard, e) + }) + msg.attachments(attachments) + msg.attachmentLayout(AttachmentLayout.carousel) + } else { + throw new BadRequestError('Message type non-supported by Microsoft : '.concat(type)) + } + return msg + } + + static async sendMessage (conversation, message) { + const channel = conversation.channel + const bot = microsoftGetBot(channel) + const address = conversation.microsoftAddress + bot.send(message.address(address)) + } + +} From 11e99afaeb6f48193ce1b3d8099528fd4407bb35 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:41:59 +0200 Subject: [PATCH 13/37] feat(service) add Twitter service --- src/services/SlackApp.service.js | 2 +- src/services/Twitter.service.js | 267 +++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/services/Twitter.service.js diff --git a/src/services/SlackApp.service.js b/src/services/SlackApp.service.js index 898a2d0..cf32c23 100644 --- a/src/services/SlackApp.service.js +++ b/src/services/SlackApp.service.js @@ -35,7 +35,7 @@ export default class SlackAppService extends ServiceTemplate { } static onChannelCreate (channel) { - channel.oAuthUrl = `${config.gromit_base_url}/v1/oauth/slack/${channel._id}` + channel.oAuthUrl = `${config.base_url}/v1/oauth/slack/${channel._id}` channel.save() } diff --git a/src/services/Twitter.service.js b/src/services/Twitter.service.js new file mode 100644 index 0000000..595a514 --- /dev/null +++ b/src/services/Twitter.service.js @@ -0,0 +1,267 @@ +import _ from 'lodash' + +import Template from './Template.service' +import { Logger, getTwitterWebhookToken, deleteTwitterWebhook, postMediaToTwitterFromUrl } from '../utils' +import { BadRequestError, ForbiddenError, StopPipeline } from '../utils/errors' +import Twit from 'twit' +import { URL } from 'url' + +/* + * checkParamsValidity: ok + * onChannelCreate: ok + * onChannelUpdate: ok + * onChannelDelete: ok + * onWebhookChecking: ok + * checkSecurity: ok + * beforePipeline: default + * extractOptions: ok + * getRawMessage: default + * sendIsTyping: default + * updateConversationWithMessage: default + * parseChannelMessage: ok + * formatMessage: ok + * sendMessage: ok + */ + +const VIDEO_AS_LINK_HOSTS = [ + 'youtube.com', + 'youtu.be', +] + +export default class Twitter extends Template { + + static checkParamsValidity (channel) { + const params = ['consumerKey', 'consumerSecret', 'accessToken', 'accessTokenSecret'] + params.forEach(param => { + if (!channel[param] || typeof channel[param] !== 'string') { + throw new BadRequestError('Bad parameter '.concat(param).concat(' : missing or not string')) + } + }) + } + + static async onChannelCreate (channel) { + const T = new Twit({ + consumer_key: channel.consumerKey, + consumer_secret: channel.consumerSecret, + access_token: channel.accessToken, + access_token_secret: channel.accessTokenSecret, + timeout_ms: 60 * 1000, + }) + + try { + // Get the previously set webhook + const res = await T.get('account_activity/webhooks', {}) + + if (!res.data || res.data.length === 0) { + throw new Error() + } + + // Try to delete the webhook if there's one + await new Promise((resolve, reject) => { + T._buildReqOpts('DELETE', `account_activity/webhooks/${res.data[0].id}`, {}, false, (err, reqOpts) => { + if (err) { return reject(err) } + + T._doRestApiRequest(reqOpts, {}, 'DELETE', (err, parsedBody) => { + if (err) { return reject(err) } + return resolve(parsedBody) + }) + }) + }) + } catch (err) { + Logger.info('[Twitter] unable to get and delete previously set webhook') + } + + try { + const res = await T.post('account_activity/webhooks', { url: channel.webhook }) + const ret = res.data + + channel.isErrored = ret.valid !== true || ret.url !== channel.webhook || !ret.id + if (!channel.isErrored) { + channel.webhookToken = ret.id + await T.post('account_activity/webhooks/'.concat(channel.webhookToken).concat('/subscriptions'), {}) + const account = await T.get('account/verify_credentials', {}) + channel.clientId = account.data.id_str + } + } catch (err) { + Logger.inspect('[Twitter] unable to set the webhook') + channel.isErrored = true + } + + return channel.save() + } + + static async onChannelUpdate (channel, oldChannel) { + await Twitter.onChannelDelete(oldChannel) + await Twitter.onChannelCreate(channel) + } + + static async onChannelDelete (channel) { + const T = new Twit({ + consumer_key: channel.consumerKey, + consumer_secret: channel.consumerSecret, + access_token: channel.accessToken, + access_token_secret: channel.accessTokenSecret, + timeout_ms: 60 * 1000, + }) + + try { + await deleteTwitterWebhook(T, channel.webhookToken) + } catch (err) { + Logger.info('[Twitter] Error while unsetting webhook : ', err) + } + } + + static onWebhookChecking (req, res, channel) { + if (!channel.consumerSecret) { + throw new BadRequestError('Error while checking webhook validity : no channel.consumerSecret') + } + + const crcToken = req.query.crc_token + const sha = getTwitterWebhookToken(channel.consumerSecret, crcToken) + res.status(200).json({ response_token: sha }) + } + + static checkSecurity (req, res, channel) { + const hash = req.headers['X-Twitter-Webhooks-Signature'] || req.headers['x-twitter-webhooks-signature'] || '' + const test = getTwitterWebhookToken(channel.consumerSecret, req.rawBody) + + if (hash.startsWith('sha256=') && hash === test) { + res.status(200).send() + } else { + throw new ForbiddenError('Invalid Twitter signature') + } + } + + static extractOptions (req) { + const recipientId = _.get(req, 'body.direct_message_events[0].message_create.target.recipient_id') + const senderId = _.get(req, 'body.direct_message_events[0].message_create.sender_id') + + return { + chatId: `${recipientId}-${senderId}`, + senderId, + } + } + + static async parseChannelMessage (conversation, message, opts) { + message = _.get(message, 'direct_message_events[0]') + const channel = conversation.channel + const senderId = _.get(message, 'message_create.sender_id') + const recipientId = _.get(message, 'message_create.target.recipient_id') + + // can be an echo message + if (senderId !== opts.senderId || senderId === channel.clientId || recipientId !== channel.clientId) { + throw new StopPipeline() + } + const data = _.get(message, 'message_create.message_data') + if (!data) { + throw new StopPipeline() + } + const msg = {} + const hasMedia = (_.get(data, 'attachment.type') === 'media') + if (!hasMedia) { + msg.attachment = { type: 'text', content: _.get(data, 'text') } + } else { + const media = _.get(data, 'attachment.media') + let type = media.type + if (type === 'photo' || type === 'animated_gif') { + type = 'picture' + } else if (type === 'video') { + type = 'video' + } else { + throw new StopPipeline() + } + msg.attachment = { type, content: media.media_url } + } + return Promise.all([conversation, msg, { ...opts, mentioned: true }]) + } + + static async formatMessage (conversation, message, opts) { + const { type, content } = _.get(message, 'attachment') + let data = [{ text: '' }] + + const makeListElement = async ({ title, imageUrl, subtitle, buttons }) => { + const msg = {} + const mediaId = await postMediaToTwitterFromUrl(conversation.channel, imageUrl) + msg.attachment = { type: 'media', media: { id: mediaId } } + msg.text = `${title}\r\n` + if (subtitle) { + msg.text += subtitle.concat('\r\n') + } + buttons = buttons.map(({ title }) => `- ${title}`).join('\r\n') + msg.text += buttons + return msg + } + + if (type === 'text') { + data[0].text = content + } else if (type === 'video' || type === 'picture') { + let hostname = (new URL(content)).hostname + if (hostname.startsWith('www.')) { + hostname = hostname.slice(4, hostname.length) + } + if (type === 'video' && VIDEO_AS_LINK_HOSTS.indexOf(hostname) !== -1) { + data[0].text = content + } else { + const mediaId = await postMediaToTwitterFromUrl(conversation.channel, content) + data[0].attachment = { type: 'media', media: { id: mediaId } } + } + } else if (type === 'quickReplies') { + data[0].text = content.title + const options = content.buttons + .map(({ title, value }) => ({ label: title, metadata: value })) + data[0].quick_reply = { type: 'options', options } + } else if (type === 'card') { + const { title, subtitle, imageUrl, buttons } = content + const mediaId = await postMediaToTwitterFromUrl(conversation.channel, imageUrl) + data[0].attachment = { type: 'media', media: { id: mediaId } } + data[0].text = title + + data.push({}) + data[1] = { text: subtitle.length > 0 ? subtitle : '.' } + const options = buttons + .map(({ title, value }) => ({ label: title, metadata: value })) + data[1].quick_reply = { type: 'options', options } + } else if (type === 'carousel' || type === 'carouselle') { + data = await Promise.all(content + .map(makeListElement)) + } else if (type === 'list') { + data = await Promise.all(content.elements + .map(makeListElement)) + if (content.buttons) { + const options = content.buttons + .map(({ title, value }) => ({ label: title, metadata: value })) + data.push({ text: '_', quick_reply: { type: 'options', options } }) + } + } else { + throw new BadRequestError('Message type non-supported by Twitter : '.concat(type)) + } + + const makeMessage = (data) => { + return { + event: { + type: 'message_create', + message_create: { + target: { recipient_id: opts.senderId }, + message_data: data, + }, + }, + } + } + + return data.map(makeMessage) + } + + static async sendMessage (conversation, message) { + const channel = conversation.channel + const T = new Twit({ + consumer_key: channel.consumerKey, + consumer_secret: channel.consumerSecret, + access_token: channel.accessToken, + access_token_secret: channel.accessTokenSecret, + timeout_ms: 60 * 1000, + }) + + await T.post('direct_messages/events/new', message) + } + +} From c32d02f603d6a834293f6b8450ada9ad8230488c Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:55:55 +0200 Subject: [PATCH 14/37] refactor(utils) update utils --- src/utils/index.js | 1 - src/utils/init.js | 11 ----------- 2 files changed, 12 deletions(-) delete mode 100644 src/utils/init.js diff --git a/src/utils/index.js b/src/utils/index.js index 21f4785..6ec8b67 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -30,5 +30,4 @@ export { } from './responses' export Logger from './Logger' -export { initServices } from './init' export { messageTypes, isValidFormatMessage } from './format' diff --git a/src/utils/init.js b/src/utils/init.js deleted file mode 100644 index 3bdf054..0000000 --- a/src/utils/init.js +++ /dev/null @@ -1,11 +0,0 @@ -import _ from 'lodash' - -/** - * Starts all services - */ -export function initServices () { - - _.forOwn(services, (service) => { - service.onLaunch() - }) -} From 6c922700dc0878eeef36156b4d204974ba6e57c7 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:56:14 +0200 Subject: [PATCH 15/37] refactor(routes) update routes handler --- src/routes/Channels.routes.js | 12 ++++++------ src/routes/Connectors.routes.js | 8 ++++---- src/routes/Conversations.routes.js | 6 +++--- src/routes/Messages.routes.js | 2 +- src/routes/Participants.routes.js | 4 ++-- src/routes/Webhooks.routes.js | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/routes/Channels.routes.js b/src/routes/Channels.routes.js index 4bf6fc3..3087565 100644 --- a/src/routes/Channels.routes.js +++ b/src/routes/Channels.routes.js @@ -40,8 +40,8 @@ export default [ { method: 'POST', path: '/connectors/:connector_id/channels', - validators: [channelValidators.createChannelByConnectorId], - handler: controllers.Channels.createChannelByConnectorId, + validators: [channelValidators.create], + handler: controllers.Channels.create, }, /** @@ -72,7 +72,7 @@ export default [ method: 'GET', path: '/connectors/:connector_id/channels', validators: [], - handler: controllers.Channels.getChannelsByConnectorId, + handler: controllers.Channels.index, }, /** @@ -106,7 +106,7 @@ export default [ method: 'GET', path: '/connectors/:connector_id/channels/:channel_slug', validators: [], - handler: controllers.Channels.getChannelByConnectorId, + handler: controllers.Channels.show, }, /** @@ -149,7 +149,7 @@ export default [ method: 'PUT', path: '/connectors/:connector_id/channels/:channel_slug', validators: [], - handler: controllers.Channels.updateChannelByConnectorId, + handler: controllers.Channels.update, }, /** @@ -167,6 +167,6 @@ export default [ method: 'DELETE', path: '/connectors/:connector_id/channels/:channel_slug', validators: [], - handler: controllers.Channels.deleteChannelByConnectorId, + handler: controllers.Channels.delete, }, ] diff --git a/src/routes/Connectors.routes.js b/src/routes/Connectors.routes.js index 10262b0..bf9f0be 100644 --- a/src/routes/Connectors.routes.js +++ b/src/routes/Connectors.routes.js @@ -27,7 +27,7 @@ export default [ method: 'GET', path: '/connectors/:connector_id', validators: [], - handler: controllers.Connectors.getConnectorByBotId, + handler: controllers.Connectors.show, }, /** @@ -55,7 +55,7 @@ export default [ method: 'POST', path: '/connectors', validators: [connectorValidators.createConnector], - handler: controllers.Connectors.createConnector, + handler: controllers.Connectors.create, }, /** @@ -86,7 +86,7 @@ export default [ method: 'PUT', path: '/connectors/:connector_id', validators: [connectorValidators.updateConnectorByBotId], - handler: controllers.Connectors.updateConnectorByBotId, + handler: controllers.Connectors.update, }, /** @@ -105,6 +105,6 @@ export default [ method: 'DELETE', path: '/connectors/:connector_id', validators: [], - handler: controllers.Connectors.deleteConnectorByBotId, + handler: controllers.Connectors.delete, }, ] diff --git a/src/routes/Conversations.routes.js b/src/routes/Conversations.routes.js index 2deff43..0f8e0de 100644 --- a/src/routes/Conversations.routes.js +++ b/src/routes/Conversations.routes.js @@ -20,7 +20,7 @@ export default [ method: 'GET', path: '/connectors/:connector_id/conversations', validators: [], - handler: controllers.Conversations.getConversationsByConnectorId, + handler: controllers.Conversations.index, }, /** @@ -50,7 +50,7 @@ export default [ method: 'GET', path: '/connectors/:connector_id/conversations/:conversation_id', validators: [], - handler: controllers.Conversations.getConversationByConnectorId, + handler: controllers.Conversations.show, }, /** @@ -71,7 +71,7 @@ export default [ method: 'DELETE', path: '/connectors/:connector_id/conversations/:conversation_id', validators: [], - handler: controllers.Conversations.deleteConversationByConnectorId, + handler: controllers.Conversations.delete, }, ] diff --git a/src/routes/Messages.routes.js b/src/routes/Messages.routes.js index fdcaed2..849bb17 100644 --- a/src/routes/Messages.routes.js +++ b/src/routes/Messages.routes.js @@ -108,6 +108,6 @@ export default [ method: 'POST', path: '/connectors/:connector_id/messages', validators: [], - handler: controllers.Messages.postMessages, + handler: controllers.Messages.broadcastMessage, }, ] diff --git a/src/routes/Participants.routes.js b/src/routes/Participants.routes.js index 449c66b..7e79de5 100644 --- a/src/routes/Participants.routes.js +++ b/src/routes/Participants.routes.js @@ -22,7 +22,7 @@ export default [ method: 'GET', path: '/connectors/:connector_id/participants', validators: [], - handler: controllers.Participants.getParticipantsByConnectorId, + handler: controllers.Participants.index, }, /** @@ -48,6 +48,6 @@ export default [ method: 'GET', path: '/connectors/:connector_id/participants/:participant_id', validators: [], - handler: controllers.Participants.getParticipantByConnectorId, + handler: controllers.Participants.show, }, ] diff --git a/src/routes/Webhooks.routes.js b/src/routes/Webhooks.routes.js index d9c957d..fa5685b 100644 --- a/src/routes/Webhooks.routes.js +++ b/src/routes/Webhooks.routes.js @@ -21,6 +21,6 @@ export default [ method: 'GET', path: '/webhook/:channel_id', validators: [], - handler: controllers.Webhooks.subscribeFacebookWebhook, + handler: controllers.Webhooks.subscribeWebhook, }, ] From 305e2123b36b2fa161861378dac8b8f61385dfec Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 17:56:58 +0200 Subject: [PATCH 16/37] chore(deps) update dependencies --- package.json | 5 ++ src/index.js | 3 +- yarn.lock | 174 +++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 168 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index fbf2f9e..db26f1b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "babel-plugin-transform-runtime": "^6.15.0", "blueimp-md5": "^2.5.0", "body-parser": "^1.15.2", + "botbuilder": "^3.8.4", "callr": "^1.0.0", "chai-spies": "^0.7.1", "cors": "^2.8.1", @@ -65,17 +66,21 @@ "eslint": "^3.7.1", "eslint-config-zavatta": "^4.2.0", "express": "^4.14.0", + "file-type": "^5.2.0", "filter-object": "^2.1.0", "is_js": "^0.9.0", "istanbul": "^0.4.5", "lodash": "^4.16.4", "mocha": "^3.1.2", "mongoose": "^4.6.3", + "node-sparky": "^4.0.8", "recursive-readdir": "^2.1.0", "sinon": "^1.17.6", "superagent": "^2.3.0", "superagent-promise": "^1.1.0", + "tmp": "^0.0.31", "tsscmp": "^1.0.5", + "twit": "git://github.com/dbousque/twit.git", "uuid": "^3.0.1" }, "devDependencies": { diff --git a/src/index.js b/src/index.js index a860708..3dae151 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ import bodyParser from 'body-parser' import _ from 'lodash' import configs from '../config' -import { initServices, Logger } from './utils' +import { Logger } from './utils' const app = express() @@ -64,7 +64,6 @@ db.on('error', err => { // Launch the application db.once('open', () => { createRouter(app) - initServices() app.listen(config.server.port, () => { app.emit('ready') Logger.info(`App is running and listening to port ${config.server.port}`) diff --git a/yarn.lock b/yarn.lock index ef30ac9..26afc1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -166,6 +166,10 @@ arrify@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" +asap@~2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" + asn1@~0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" @@ -878,6 +882,10 @@ balanced-match@^0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" +base64url@2.0.0, base64url@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" + bcrypt-pbkdf@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz#3ca76b85241c7170bf7d9703e7b9aa74630040d4" @@ -898,7 +906,7 @@ bluebird@2.10.2: version "2.10.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.10.2.tgz#024a5517295308857f14f91f1106fc3b555f446b" -bluebird@^3.3.3: +bluebird@^3.1.5, bluebird@^3.3.3: version "3.4.6" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" @@ -927,6 +935,20 @@ boom@2.x.x: dependencies: hoek "2.x.x" +botbuilder@^3.8.4: + version "3.8.4" + resolved "https://registry.yarnpkg.com/botbuilder/-/botbuilder-3.8.4.tgz#fcedc42b927ecf050c24be520c1280824437a708" + dependencies: + async "^1.5.2" + base64url "^2.0.0" + chrono-node "^1.1.3" + jsonwebtoken "^7.0.1" + promise "^7.1.1" + request "^2.69.0" + rsa-pem-from-mod-exp "^0.8.4" + sprintf-js "^1.0.3" + url-join "^1.1.0" + brace-expansion@^1.0.0: version "1.1.6" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" @@ -950,6 +972,10 @@ bson@~0.5.4, bson@~0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/bson/-/bson-0.5.7.tgz#0d11fe0936c1fee029e11f7063f5d0ab2422ea3e" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + buffer-shims@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" @@ -1048,6 +1074,12 @@ chokidar@^1.0.0, chokidar@^1.4.3: optionalDependencies: fsevents "^1.0.0" +chrono-node@^1.1.3: + version "1.3.4" + resolved "https://registry.yarnpkg.com/chrono-node/-/chrono-node-1.3.4.tgz#fc2a9208636e09d6fd7b12d94ae2440937de24bd" + dependencies: + moment "^2.10.3" + circular-json@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d" @@ -1300,6 +1332,13 @@ ecc-jsbn@~0.1.1: dependencies: jsbn "~0.1.0" +ecdsa-sig-formatter@1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1" + dependencies: + base64url "^2.0.0" + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -1599,6 +1638,10 @@ file-entry-cache@^2.0.0: flat-cache "^1.2.1" object-assign "^4.0.1" +file-type@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" + filename-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.0.tgz#996e3e80479b98b9897f15a8a58b3d084e926775" @@ -2164,6 +2207,10 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" +isemail@1.x.x: + version "1.2.0" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-1.2.0.tgz#be03df8cc3e29de4d2c5df6501263f1fa4595e9a" + isexe@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0" @@ -2203,6 +2250,15 @@ jodid25519@^1.0.0: dependencies: jsbn "~0.1.0" +joi@^6.10.1: + version "6.10.1" + resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" + dependencies: + hoek "2.x.x" + isemail "1.x.x" + moment "2.x.x" + topo "1.x.x" + js-tokens@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-2.0.0.tgz#79903f5563ee778cc1162e6dcf1a0027c97f9cb5" @@ -2262,6 +2318,16 @@ jsonpointer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.0.tgz#6661e161d2fc445f19f98430231343722e1fcbd5" +jsonwebtoken@^7.0.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.1.tgz#7ca324f5215f8be039cd35a6c45bb8cb74a448fb" + dependencies: + joi "^6.10.1" + jws "^3.1.4" + lodash.once "^4.0.0" + ms "^2.0.0" + xtend "^4.0.1" + jsprim@^1.2.2: version "1.3.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.3.1.tgz#2a7256f70412a29ee3670aaca625994c4dcff252" @@ -2270,6 +2336,23 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.3.6" +jwa@^1.1.4: + version "1.1.5" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5" + dependencies: + base64url "2.0.0" + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.9" + safe-buffer "^5.0.1" + +jws@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2" + dependencies: + base64url "^2.0.0" + jwa "^1.1.4" + safe-buffer "^5.0.1" + kareem@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/kareem/-/kareem-1.1.3.tgz#0877610d8879c38da62d1dbafde4e17f2692f041" @@ -2389,6 +2472,10 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + lodash.pickby@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" @@ -2397,6 +2484,10 @@ lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" +lodash@4.13.1: + version "4.13.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.13.1.tgz#83e4b10913f48496d4d16fec4a560af2ee744b68" + lodash@^4.0.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.16.4, lodash@^4.2.0, lodash@^4.3.0: version "4.17.2" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.2.tgz#34a3055babe04ce42467b607d700072c7ff6bf42" @@ -2482,15 +2573,15 @@ micromatch@^2.1.5, micromatch@^2.2.0, micromatch@^2.3.7: parse-glob "^3.0.4" regex-cache "^0.4.2" -mime-db@~1.25.0: - version "1.25.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.25.0.tgz#c18dbd7c73a5dbf6f44a024dc0d165a1e7b1c392" +mime-db@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" -mime-types@^2.1.10, mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: - version "2.1.13" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.13.tgz#e07aaa9c6c6b9a7ca3012c69003ad25a39e92a88" +mime-types@^2.1.10, mime-types@^2.1.12, mime-types@^2.1.14, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: + version "2.1.15" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" dependencies: - mime-db "~1.25.0" + mime-db "~1.27.0" mime@1.3.4, mime@^1.3.4: version "1.3.4" @@ -2538,6 +2629,10 @@ mock-require@^1.3.0: dependencies: caller-id "^0.1.0" +moment@2.x.x, moment@^2.10.3: + version "2.18.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" + mongodb-core@2.0.13: version "2.0.13" resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.0.13.tgz#f9394b588dce0e579482e53d74dbc7d7a9d4519c" @@ -2591,6 +2686,10 @@ ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" +ms@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + muri@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/muri/-/muri-1.1.1.tgz#64bd904eaf8ff89600c994441fad3c5195905ac2" @@ -2644,6 +2743,15 @@ node-pre-gyp@^0.6.29: tar "~2.2.1" tar-pack "~3.3.0" +node-sparky@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/node-sparky/-/node-sparky-4.0.8.tgz#d05510548e5da111df93e21c9d57b4e45e46799d" + dependencies: + lodash "4.13.1" + mime-types "^2.1.14" + request "^2.79.0" + when "3.7.7" + nodemon@^1.11.0: version "1.11.0" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.11.0.tgz#226c562bd2a7b13d3d7518b49ad4828a3623d06c" @@ -2768,7 +2876,7 @@ os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" -os-tmpdir@^1.0.0, os-tmpdir@^1.0.1: +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" @@ -2871,6 +2979,12 @@ progress@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + dependencies: + asap "~2.0.3" + propagate@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/propagate/-/propagate-0.4.0.tgz#f3fcca0a6fe06736a7ba572966069617c130b481" @@ -3066,7 +3180,7 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -request@^2.64.0, request@^2.75.0: +request@^2.64.0, request@^2.68.0, request@^2.69.0, request@^2.75.0, request@^2.79.0: version "2.79.0" resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" dependencies: @@ -3140,6 +3254,10 @@ rimraf@2, rimraf@^2.2.8, rimraf@~2.5.1, rimraf@~2.5.4: dependencies: glob "^7.0.5" +rsa-pem-from-mod-exp@^0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.4.tgz#362a42c6d304056d493b3f12bceabb2c6576a6d4" + run-async@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" @@ -3150,6 +3268,10 @@ rx-lite@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" +safe-buffer@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" + samsam@1.1.2, samsam@~1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" @@ -3286,7 +3408,7 @@ split@0.3: dependencies: through "2" -sprintf-js@~1.0.2: +sprintf-js@^1.0.3, sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -3443,10 +3565,22 @@ timed-out@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-2.0.0.tgz#f38b0ae81d3747d628001f41dafc652ace671c0a" +tmp@^0.0.31: + version "0.0.31" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" + dependencies: + os-tmpdir "~1.0.1" + to-fast-properties@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.2.tgz#f3f5c0c3ba7299a7ef99427e44633257ade43320" +topo@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" + dependencies: + hoek "2.x.x" + touch@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de" @@ -3475,6 +3609,14 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.3" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.3.tgz#3da382f670f25ded78d7b3d1792119bca0b7132d" +"twit@git://github.com/dbousque/twit.git": + version "2.2.5" + resolved "git://github.com/dbousque/twit.git#38c4ce541169f2b2bec7519ccfd81abf97d04095" + dependencies: + bluebird "^3.1.5" + mime "^1.3.4" + request "^2.68.0" + type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" @@ -3553,6 +3695,10 @@ url-join@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8" +url-join@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-1.1.0.tgz#741c6c2f4596c4830d6718460920d0c92202dc78" + user-home@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" @@ -3601,6 +3747,10 @@ verror@1.3.6: dependencies: extsprintf "1.0.2" +when@3.7.7: + version "3.7.7" + resolved "https://registry.yarnpkg.com/when/-/when-3.7.7.tgz#aba03fc3bb736d6c88b091d013d8a8e590d84718" + which@^1.1.1, which@^1.2.9: version "1.2.12" resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" @@ -3687,7 +3837,7 @@ xdg-basedir@^2.0.0: dependencies: os-homedir "^1.0.0" -xtend@^4.0.0: +xtend@^4.0.0, xtend@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" From e89b926e655e94496167dedd86d824003620e7f0 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Mon, 26 Jun 2017 18:08:35 +0200 Subject: [PATCH 17/37] chore(doc) update README --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 73aaa7f..6129b06 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,19 @@ Bot Connector supports the following channels: * [Callr](https://github.com/RecastAI/bot-connector/wiki/Channel-CALLR) * [Telegram](https://github.com/RecastAI/bot-connector/wiki/Channel-Telegram) * [Twilio](https://github.com/RecastAI/bot-connector/wiki/Channel-Twilio) +* Cisco Spark +* Microsoft Teams +* Skype +* Twitter Direct Message You can find more information on each channel in the [wiki](https://github.com/RecastAI/bot-connector/wiki) More will be added, and you can [contribute](https://github.com/RecastAI/bot-connector/blob/master/CONTRIBUTING.md) if you want to, and add a thumbs up for the channel you want to see implemented first ;) (To do so, fork this repo, add a thumbs up and make a PR!) -* Cisco Spark 👍👍 * Discord 👍 * Line 👍 -* Microsoft Teams 👍 * Ryver 👍 -* Skype 👍 * Viber * Wechat 👍 * Zinc.it 👍 From bc13020892209bb2576b62c0bea6e6468038a8e4 Mon Sep 17 00:00:00 2001 From: jhoudan Date: Wed, 28 Jun 2017 13:59:22 +0200 Subject: [PATCH 18/37] refactor(core) minor improvments --- src/controllers/Channels.controller.js | 11 ++++------- src/services/Telegram.service.js | 4 ++-- src/validators/Channels.validators.js | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/controllers/Channels.controller.js b/src/controllers/Channels.controller.js index 9b94b05..cf5b92f 100644 --- a/src/controllers/Channels.controller.js +++ b/src/controllers/Channels.controller.js @@ -23,14 +23,11 @@ export default class ChannelsController { if (!connector) { throw new NotFoundError('Connector') - } - - const channel = connector.channels.find(c => c.slug === slug) - - if (channel) { + } else if (connector.channels.find(c => c.slug === slug)) { throw new ConflictError('Channel slug is already taken') } + const channel = await global.models.Channel({ ...params, connector: connector._id }) channel.webhook = `${global.config.gromit_base_url}/v1/webhook/${channel._id}` connector.channels.push(channel) @@ -72,7 +69,7 @@ export default class ChannelsController { static async show (req, res) { const { connector_id, channel_slug } = req.params - const channel = models.Channel.findOne({ slug: channel_slug, connector: connector_id }) + const channel = await models.Channel.findOne({ slug: channel_slug, connector: connector_id }) .populate('children') if (!channel) { @@ -93,7 +90,7 @@ export default class ChannelsController { const oldChannel = await global.models.Channel.findOne({ slug: channel_slug, connector: connector_id }) const channel = await global.models.Channel.findOneAndUpdate( - { slug: channel_slug, connector: connector._id, isActive: true }, + { slug: channel_slug, connector: connector_id }, { $set: filter(req.body, permittedUpdate) }, { new: true } ) diff --git a/src/services/Telegram.service.js b/src/services/Telegram.service.js index e64890a..a9280d4 100644 --- a/src/services/Telegram.service.js +++ b/src/services/Telegram.service.js @@ -4,7 +4,7 @@ import superAgentPromise from 'superagent-promise' import { Logger } from '../utils' import Template from './Template.service' -import { ValidationError, BadRequestError } from '../utils/errors' +import { BadRequestError } from '../utils/errors' const agent = superAgentPromise(superAgent, Promise) @@ -29,7 +29,7 @@ export default class Telegram extends Template { static checkParamsValidity (channel) { if (!channel.token) { - throw new ValidationError('token', 'missing') + throw new BadRequestError('token', 'missing') } } diff --git a/src/validators/Channels.validators.js b/src/validators/Channels.validators.js index 1aaedbe..7fe0383 100644 --- a/src/validators/Channels.validators.js +++ b/src/validators/Channels.validators.js @@ -5,7 +5,7 @@ import { BadRequestError } from '../utils/errors' const permitted = '{type,slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,password,phoneNumber,serviceId}' -export async function createChannelByConnectorId (req) { +export async function create (req) { const { slug, type } = req.body const newChannel = new models.Channel(filter(req.body, permitted)) From 62b87cb8b6aaf0b11821f1902f7daa2170e7de7e Mon Sep 17 00:00:00 2001 From: jhoudan Date: Wed, 28 Jun 2017 14:49:32 +0200 Subject: [PATCH 19/37] refactor(core) minor updates --- src/controllers/Channels.controller.js | 2 +- src/controllers/Conversations.controller.js | 11 ++++------- src/controllers/Messages.controller.js | 1 - src/controllers/Participants.controller.js | 4 ++-- src/controllers/Webhooks.controller.js | 8 ++++---- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/controllers/Channels.controller.js b/src/controllers/Channels.controller.js index cf5b92f..49a544c 100644 --- a/src/controllers/Channels.controller.js +++ b/src/controllers/Channels.controller.js @@ -28,7 +28,7 @@ export default class ChannelsController { } const channel = await global.models.Channel({ ...params, connector: connector._id }) - channel.webhook = `${global.config.gromit_base_url}/v1/webhook/${channel._id}` + channel.webhook = `${global.config.base_url}/webhook/${channel._id}` connector.channels.push(channel) await Promise.all([ diff --git a/src/controllers/Conversations.controller.js b/src/controllers/Conversations.controller.js index aa2ea88..525d332 100644 --- a/src/controllers/Conversations.controller.js +++ b/src/controllers/Conversations.controller.js @@ -1,6 +1,3 @@ -import _ from 'lodash' - -import { Logger } from '../utils' import { renderOk, renderDeleted } from '../utils/responses' import { NotFoundError, BadRequestError } from '../utils/errors' @@ -9,7 +6,7 @@ export default class ConversationController { static async index (req, res) { const { connector_id } = req.params - const conversations = models.Conversation.find({ connector: connector_id }) + const conversations = await models.Conversation.find({ connector: connector_id }) return renderOk(res, { results: conversations.map(c => c.serialize), @@ -18,9 +15,9 @@ export default class ConversationController { } static async show (req, res) { - const { connector_id, conversation_id } = req.params + const { conversation_id, connector_id } = req.params - const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector._id }) + const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) .populate('participants messages') if (!conversation) { @@ -36,7 +33,7 @@ export default class ConversationController { static async delete (req, res) { const { connector_id, conversation_id } = req.params - const conversations = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) + const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) if (!conversation) { throw new NotFoundError('Conversation') diff --git a/src/controllers/Messages.controller.js b/src/controllers/Messages.controller.js index 97cc2c8..d207f2a 100644 --- a/src/controllers/Messages.controller.js +++ b/src/controllers/Messages.controller.js @@ -183,7 +183,6 @@ export default class MessagesController { } } - static async broadcastMessage (req, res) { const { connector_id } = req.params let { messages } = req.body diff --git a/src/controllers/Participants.controller.js b/src/controllers/Participants.controller.js index 59ef13f..3ffa5df 100644 --- a/src/controllers/Participants.controller.js +++ b/src/controllers/Participants.controller.js @@ -6,7 +6,7 @@ export default class ParticipantController { /* * Index all connector's participants */ - static async getParticipantsByConnectorId (req, res) { + static async index (req, res) { const { connector_id } = req.params const results = [] @@ -25,7 +25,7 @@ export default class ParticipantController { /* * Show a participant */ - static async getParticipantByConnectorId (req, res) { + static async show (req, res) { const { participant_id } = req.params const participant = await models.Participant.findById(participant_id) diff --git a/src/controllers/Webhooks.controller.js b/src/controllers/Webhooks.controller.js index 46ed287..f35296d 100644 --- a/src/controllers/Webhooks.controller.js +++ b/src/controllers/Webhooks.controller.js @@ -22,7 +22,7 @@ export default class WebhooksController { throw new BadRequestError('Type is not defined') } - invokeSync(channel.type, 'checkSecurity', [req, res, channel]) + await invoke(channel.type, 'checkSecurity', [req, res, channel]) channel = await invoke(channel.type, 'beforePipeline', [req, res, channel]) const options = invokeSync(channel.type, 'extractOptions', [req, res, channel]) @@ -31,14 +31,14 @@ export default class WebhooksController { invoke(channel.type, 'sendIsTyping', [channel, options, req.body]) } - const message = await invoke(channel._id, 'getRawMessage', [channel, req, options]) + const message = await invoke(channel.type, 'getRawMessage', [channel, req, options]) await controllers.Messages.pipeMessage(channel._id, message, options) } - static async subscribeWebhhok (req, res) { + static async subscribeWebhook (req, res) { const { channel_id } = req.params - const channel = await global.models.Channel.findByIf(channel_id) + const channel = await global.models.Channel.findById(channel_id) if (!channel) { throw new NotFoundError('Channel') From 91cc479c81cf06f2c20d14f5b3836ee0c3c98d79 Mon Sep 17 00:00:00 2001 From: Jerome Houdan Date: Wed, 28 Jun 2017 15:21:05 +0200 Subject: [PATCH 20/37] Update README.md --- README.md | 132 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b064ef6..c77ea70 100644 --- a/README.md +++ b/README.md @@ -115,66 +115,122 @@ To send a new message, you have to post it to Bot Connector's API All messages coming from the bot are parsed and modified to match the destination channel specifications. Bot Connector supports several message formats: -* Text message: +* Text -```javascript -[{ +```js +{ type: 'text', - content: 'My text message', -}] + content: 'MY_TEXT', +} ``` -* Quick Replies: -```javascript -[{ +* Picture + +```js +{ + type: 'picture', + content: 'IMAGE_URL', +} +``` + +* Video + +```js +{ + type: 'video', + content: 'VIDEO_URL', +} +``` + +* Quick Replies + +```js +{ type: 'quickReplies', content: { - title: 'My title', + title: 'TITLE', + buttons: [ + { + title: 'BUTTON_1_TITLE', + value: 'BUTTON_1_VALUE', + }, { + title: 'BUTTON_2_TITLE', + value: 'BUTTON_2_VALUE', + } + ] + } +} +``` + +* List +```js +{ + type: 'list', + content: { + elements: [ + { + title: 'ELEM_1_TITLE', + imageUrl: 'IMAGE_URL', + subtitle: 'ELEM_1_SUBTITLE', + buttons: [ + { + title: 'BUTTON_1_TITLE', + value: 'BUTTON_1_VALUE', + type: 'BUTTON_TYPE', + } + ] + } + ], buttons: [ { - title: 'Button title', - value: 'Button value', - }, + title: 'BUTTON_1_TITLE', + value: 'BUTTON_1_VALUE', + type: 'BUTTON_TYPE', + } ] } -}] +} ``` -* Cards: +* Card -```javascript -[{ +```js +{ type: 'card', content: { - title: 'My card title', - imageUrl: 'url_to_my_image', - subtitle: 'My card subtitle', + title: 'CARD_TITLE', + subtitle: 'CARD_SUBTITLE', + imageUrl: 'IMAGE_URL', buttons: [ { - title: 'My button title', - type: 'My button type', // See Facebook Messenger button formats - value: 'My button value', + title: 'BUTTON_TITLE', + type: 'BUTTON_TYPE', // See Facebook Messenger button formats + value: 'BUTTON_VALUE', } ], }, -}] -``` - -* Pictures: - -```javascript -[{ - type: 'picture', - content: 'url_to_my_image', -}] +} ``` -* Videos: -```javascript -[{ - type: 'video', - content: 'url_to_my_video', -}] +* Carousel + +```js +{ + type: 'carousel', + content: [ + { + title: 'CARD_1_TITLE', + imageUrl: 'IMAGE_URL', + buttons: [ + { + title: 'BUTTON_1_TITLE', + value: 'BUTTON_1_VALUE', + type: 'BUTTON_1_TYPE', + } + ] + } + ], +} ``` ### Issue From 619bf0bf2a62fc0957cc29a7e8f1f3f28dbb8973 Mon Sep 17 00:00:00 2001 From: Jerome Houdan Date: Wed, 28 Jun 2017 16:19:18 +0200 Subject: [PATCH 21/37] Update README.md --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c77ea70..6a0b2ca 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,9 @@ Bot Connector supports the following channels: * [Callr](https://github.com/RecastAI/bot-connector/wiki/Channel-CALLR) * [Telegram](https://github.com/RecastAI/bot-connector/wiki/Channel-Telegram) * [Twilio](https://github.com/RecastAI/bot-connector/wiki/Channel-Twilio) -* Cisco Spark -* Microsoft Teams -* Skype -* Twitter Direct Message +* [Cisco Spark](https://github.com/RecastAI/bot-connector/wiki/Channel-Cisco) +* [Microsoft Bot Framework (Skype, Teams, Cortana,...)](https://github.com/RecastAI/bot-connector/wiki/Channel-Microsoft-Bot-Framework) +* [Twitter](https://github.com/RecastAI/bot-connector/wiki/Channel-Twitter) You can find more information on each channel in the [wiki](https://github.com/RecastAI/bot-connector/wiki) From 3718582e9ac9d9da8e9dc42efd2775d4de672308 Mon Sep 17 00:00:00 2001 From: Julien Blancher Date: Thu, 23 Nov 2017 14:16:50 +0100 Subject: [PATCH 22/37] fix(license): add license file --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7fe3c33 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 RecastAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 8641a01f0c14463733fef36bbecd76b71169e4ee Mon Sep 17 00:00:00 2001 From: fadomire Date: Fri, 1 Dec 2017 14:43:17 +0100 Subject: [PATCH 23/37] fix isTyping not working ... channel.connector.isTyping was always undefined because channel.connector returned the _id instead of the ref Object --- src/controllers/Webhooks.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/Webhooks.controller.js b/src/controllers/Webhooks.controller.js index f35296d..1612e94 100644 --- a/src/controllers/Webhooks.controller.js +++ b/src/controllers/Webhooks.controller.js @@ -12,7 +12,7 @@ export default class WebhooksController { */ static async forwardMessage (req, res) { const { channel_id } = req.params - let channel = await models.Channel.findById(channel_id).populate({ path: 'children' }) + let channel = await models.Channel.findById(channel_id).populate('children').populate('connector') if (!channel) { throw new NotFoundError('Channel') From 0b39a72b70613439a3a403662340bfbc9e9e9a07 Mon Sep 17 00:00:00 2001 From: Jibran Kalia Date: Thu, 14 Dec 2017 10:39:45 -0800 Subject: [PATCH 24/37] Update CONTRIBUTING.md Updating the git clone url. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c657c76..79ff81b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ If you want to add a new channel, please check this [wiki page](https://github.c #### Installation ```sh -$ git clone https://github.com/RecastAI/Connector.git +$ git clone https://github.com/RecastAI/bot-connector.git $ cd Connector $ yarn ``` From b3530479fc35b499b082e4831aa73ab794645806 Mon Sep 17 00:00:00 2001 From: Ahirice Date: Wed, 20 Dec 2017 14:25:36 +0100 Subject: [PATCH 25/37] Update README.md --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 6a0b2ca..f998838 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ ![Bot Conector Logo](https://cdn.recast.ai/bot-connector/bot-connector-logo.png) +| [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages_format) | [Getting Started with Recast.AI](#getting_started_with_recastai) | [License](#license) | +|---|---|---|---|---|---| + # Bot Connector Bot Connector allows you to connect your bot to multiple messaging channels. @@ -240,6 +243,19 @@ If you encounter any issue, please follow this [guide](https://github.com/Recast Want to contribute? Great! Please check this [guide](https://github.com/RecastAI/bot-connector/blob/master/CONTRIBUTING.md). +## Getting started with Recast.AI + +We build products to help enterprises and developers have a better understanding of user inputs. + +- **NLP API**: a unique API for text processing, and augmented training. +- **Bot Building Tools**: all you need to create smart bots powered by Recast.AI's NLP API. Design even the most complex conversation flow, use all rich messaging formats and connect to external APIs and services. +- **Bot Connector API**: standardizes the messaging format across all channels, letting you connect your bots to any channel in minutes. + +Learn more about: + +| [API Documentation](https://recast.ai/docs/api-reference/) | [Discover the platform](https://recast.ai/docs/create-your-bot) | [First bot tutorial](https://recast.ai/blog/build-your-first-bot-with-recast-ai/) | [Advanced NodeJS tutorial](https://recast.ai/blog/nodejs-chatbot-movie-bot/) | [Advanced Python tutorial](https://recast.ai/blog/python-cryptobot/) | +|---|---|---|---|---| + ### License Copyright (c) [2016] [Recast.AI](https://recast.ai) From 3a5bf22442e321ebbdedfe722f8749bd46bed86b Mon Sep 17 00:00:00 2001 From: Ahirice Date: Wed, 20 Dec 2017 14:27:49 +0100 Subject: [PATCH 26/37] Update README.md added a navigation menu in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f998838..e4b42f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Bot Conector Logo](https://cdn.recast.ai/bot-connector/bot-connector-logo.png) -| [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages_format) | [Getting Started with Recast.AI](#getting_started_with_recastai) | [License](#license) | +| [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages-format) | [Getting Started with Recast.AI]( #getting-started-with-recastai) | [License](#license) | |---|---|---|---|---|---| # Bot Connector From 320591991e65e4ac9cfcee368a7fa7211bf91aad Mon Sep 17 00:00:00 2001 From: Ahirice Date: Wed, 20 Dec 2017 14:33:02 +0100 Subject: [PATCH 27/37] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e4b42f0..0203b07 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ | [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages-format) | [Getting Started with Recast.AI]( #getting-started-with-recastai) | [License](#license) | |---|---|---|---|---|---| + + # Bot Connector Bot Connector allows you to connect your bot to multiple messaging channels. From 3afe76c19402fbf7c46aae71ffaeff9dab1eb958 Mon Sep 17 00:00:00 2001 From: julien-leclercq Date: Fri, 1 Jun 2018 06:16:53 +0200 Subject: [PATCH 28/37] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0203b07..b814730 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ More will be added, and you can [contribute](https://github.com/RecastAI/bot-con (To do so, fork this repo, add a thumbs up and make a PR!) -* Discord 👍👍 +* Discord 👍👍👍 * Line 👍 * Ryver 👍 * Viber From 86b421081d8df0eb81728ba90e480a1a9f04928b Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Tue, 5 Jun 2018 21:47:54 +0200 Subject: [PATCH 29/37] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0203b07..b814730 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ More will be added, and you can [contribute](https://github.com/RecastAI/bot-con (To do so, fork this repo, add a thumbs up and make a PR!) -* Discord 👍👍 +* Discord 👍👍👍 * Line 👍 * Ryver 👍 * Viber From 0c117b11fbdbda476e168b6cd4d04a18b5570b83 Mon Sep 17 00:00:00 2001 From: datapharmer Date: Tue, 11 Sep 2018 17:07:29 -0400 Subject: [PATCH 30/37] salesforce thumbs up --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b814730..ca05206 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ More will be added, and you can [contribute](https://github.com/RecastAI/bot-con * Viber * Wechat 👍👍 * Zinc.it 👍 -* Salesforce +* Salesforce 👍 You can find the current roadmap [here](https://github.com/RecastAI/bot-connector/projects/1). From a5a0a88faec9827377f2e1a98c1e6eccdb65473e Mon Sep 17 00:00:00 2001 From: Rene Springer Date: Wed, 16 Jan 2019 18:10:20 -0800 Subject: [PATCH 31/37] major update with several additions, changes and restructuring --- CONTRIBUTING.md | 48 - Dockerfile | 19 + ISSUE.md | 7 - LICENSE | 2 +- README.md | 126 +- config/development.js | 12 - config/index.js | 2 +- config/production.js | 12 - config/test.js | 16 +- deploy.sh | 11 - example/bot.js | 34 - example/package.json | 27 - package-lock.json | 13958 ++++++++++++++++ package.json | 186 +- resources/kik/kik.png | Bin 93491 -> 0 bytes resources/messenger/messenger.jpg | Bin 76189 -> 0 bytes resources/slack/slack.jpg | Bin 95217 -> 0 bytes src/app.js | 92 + .../abstract_channel_integration.js | 312 + .../amazon_alexa/channel.js | 368 + .../amazon_alexa/controller.js | 66 + .../amazon_alexa/index.js | 4 + .../amazon_alexa/routes.js | 25 + src/channel_integrations/amazon_alexa/sdk.js | 129 + .../amazon_alexa/test/integration.js | 149 + src/channel_integrations/callr/channel.js | 107 + src/channel_integrations/callr/index.js | 3 + .../callr/test/integration.js | 109 + .../cisco_spark/channel.js} | 62 +- src/channel_integrations/cisco_spark/index.js | 3 + .../cisco_spark/test/integration.js | 219 + src/channel_integrations/facebook/channel.js | 447 + .../facebook/constants.js | 46 + .../facebook/controller.js | 128 + src/channel_integrations/facebook/index.js | 4 + .../facebook/middlewares.js | 53 + src/channel_integrations/facebook/routes.js | 33 + src/channel_integrations/facebook/sdk.js | 240 + .../facebook/test/integration.js | 504 + src/channel_integrations/index.js | 77 + .../kik/channel.js} | 132 +- src/channel_integrations/kik/index.js | 3 + .../kik/test/integration.js | 279 + src/channel_integrations/line/channel.js | 270 + src/channel_integrations/line/index.js | 3 + .../line/test/integration.js | 255 + .../microsoft/channel.js} | 102 +- src/channel_integrations/microsoft/index.js | 3 + .../microsoft/test/integration.js | 59 + .../microsoft/utils.js} | 7 +- .../slack/channel.js} | 122 +- src/channel_integrations/slack/index.js | 3 + .../slack/test/integration.js | 348 + src/channel_integrations/slack_app/channel.js | 161 + src/channel_integrations/slack_app/index.js | 4 + src/channel_integrations/slack_app/routes.js | 11 + .../slack_app/test/integration.js | 64 + .../slack_app/test/routes.js | 16 + src/channel_integrations/telegram/channel.js | 304 + src/channel_integrations/telegram/index.js | 3 + .../telegram/test/channel.js | 18 + .../telegram/test/integration.js | 320 + .../telegram/test/mockups.json | 205 + src/channel_integrations/twilio/channel.js | 88 + src/channel_integrations/twilio/index.js | 3 + .../twilio/test/integration.js | 137 + .../twitter/channel.js} | 180 +- src/channel_integrations/twitter/index.js | 3 + .../twitter/test/integration.js | 245 + src/channel_integrations/webchat/channel.js | 45 + src/channel_integrations/webchat/index.js | 3 + .../webchat/test/integration.js | 68 + src/constants/channels.js | 19 + src/constants/index.js | 1 + src/controllers/Channels.controller.js | 130 - src/controllers/Connectors.controller.js | 83 - src/controllers/Conversations.controller.js | 83 - src/controllers/Messages.controller.js | 225 - src/controllers/Participants.controller.js | 40 - src/controllers/Webhooks.controller.js | 79 - .../{App.controller.js => application.js} | 0 src/controllers/channels.js | 187 + src/controllers/connectors.js | 122 + src/controllers/conversations.js | 190 + src/controllers/get_started_buttons.js | 154 + src/controllers/index.js | 17 - src/controllers/message_pipe.js | 173 + src/controllers/messages.js | 252 + src/controllers/participants.js | 51 + src/controllers/persistent_menus.js | 226 + src/controllers/webhooks.js | 316 + src/index.js | 76 +- src/models/Channel.model.js | 65 - src/models/channel.js | 145 + .../{Connector.model.js => connector.js} | 31 +- ...{Conversation.model.js => conversation.js} | 23 +- src/models/get_started_button.js | 21 + src/models/index.js | 20 +- src/models/{Message.model.js => message.js} | 13 +- .../{Participant.model.js => participant.js} | 34 +- src/models/persistent_menu.js | 26 + src/routes/App.routes.js | 15 - src/routes/Channels.routes.js | 172 - src/routes/Connectors.routes.js | 110 - src/routes/Oauth.routes.js | 10 - src/routes/Webhooks.routes.js | 26 - src/routes/application.js | 18 + src/routes/channels.js | 197 + src/routes/connectors.js | 119 + ...nversations.routes.js => conversations.js} | 53 +- src/routes/get_started_buttons.js | 32 + src/routes/index.js | 48 +- .../{Messages.routes.js => messages.js} | 37 +- ...Participants.routes.js => participants.js} | 23 +- src/routes/persistent_menus.js | 53 + src/routes/webhooks.js | 60 + src/services/Callr.service.js | 147 - src/services/Messenger.service.js | 208 - src/services/SlackApp.service.js | 136 - src/services/Telegram.service.js | 181 - src/services/Template.service.js | 53 - src/services/Twilio.service.js | 135 - src/services/index.js | 23 - src/test.js | 109 - src/utils/Logger.js | 48 - src/utils/errors.js | 103 +- src/utils/format.js | 192 +- src/utils/headers.js | 13 + src/utils/index.js | 27 +- src/utils/line.js | 31 + src/utils/log.js | 28 + src/utils/mail.js | 51 + src/utils/message_queue.js | 54 + src/utils/responses.js | 38 +- src/utils/utils.js | 123 +- src/validators/Channels.validators.js | 21 - src/validators/channels.js | 46 + ...Connectors.validators.js => connectors.js} | 6 +- src/validators/index.js | 2 + src/validators/validators-config.js | 5 + test/channel_integrations/modules.js | 93 + test/controllers/Bots.controller.tests.js | 155 - test/controllers/Channels.controller.tests.js | 196 - .../Conversations.controllers.tests.js | 150 - test/controllers/Messages.controller.tests.js | 136 - .../Participants.controller.tests.js | 463 - test/controllers/application.js | 25 + test/controllers/channels.js | 334 + test/controllers/connectors.js | 146 + test/controllers/conversations.js | 197 + test/controllers/messages.js | 8 + test/controllers/oauth.js | 8 + test/controllers/participants.js | 199 + test/controllers/persistent_menus.js | 264 + test/controllers/webhooks.js | 564 + test/factories/channel.js | 22 + test/factories/connector.js | 13 + test/factories/conversation.js | 14 + test/factories/message.js | 15 + test/factories/participant.js | 17 + test/factories/persistent_menu.js | 20 + test/mocks/http.js | 13 - test/models/.gitkeep | 0 test/models/Bot.model.tests.js | 104 - test/models/Channel.model.tests.js | 75 - test/models/Conversation.model.tests.js | 95 - test/models/Participant.model.tests.js | 88 - test/routes/.gitkeep | 0 test/routes/Bots.routes.tests.js | 32 - test/routes/Channels.routes.tests.js | 27 - test/routes/Conversations.routes.tests.js | 29 - test/routes/Participants.routes.tests.js | 16 - test/routes/application.js | 23 + test/routes/channels.js | 46 + test/routes/connectors.js | 38 + test/routes/conversations.js | 42 + test/routes/messages.js | 26 + test/routes/participants.js | 22 + test/routes/persistent_menus.js | 51 + test/routes/webhooks.js | 46 + test/services/Facebook.service.tests.js | 345 - test/services/Kik.service.tests.js | 339 - test/services/Slack.service.tests.js | 403 - test/services/fetchMethod.service.js | 22 - test/tools/index.js | 14 + test/tools/integration_setup.js | 68 + test/util/message_queue.js | 96 + test/util/start_application.js | 13 + test/validators/Bots.validators.tests.js | 95 - test/validators/Channels.validators.tests.js | 174 - .../Participants.validators.tests.js | 68 - test/validators/Webhooks.validators.tests.js | 47 - yarn.lock | 3855 ----- 193 files changed, 26123 insertions(+), 9887 deletions(-) delete mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile delete mode 100644 ISSUE.md delete mode 100644 config/development.js delete mode 100644 config/production.js delete mode 100755 deploy.sh delete mode 100644 example/bot.js delete mode 100644 example/package.json create mode 100644 package-lock.json delete mode 100644 resources/kik/kik.png delete mode 100644 resources/messenger/messenger.jpg delete mode 100644 resources/slack/slack.jpg create mode 100644 src/app.js create mode 100644 src/channel_integrations/abstract_channel_integration.js create mode 100644 src/channel_integrations/amazon_alexa/channel.js create mode 100644 src/channel_integrations/amazon_alexa/controller.js create mode 100644 src/channel_integrations/amazon_alexa/index.js create mode 100644 src/channel_integrations/amazon_alexa/routes.js create mode 100644 src/channel_integrations/amazon_alexa/sdk.js create mode 100644 src/channel_integrations/amazon_alexa/test/integration.js create mode 100644 src/channel_integrations/callr/channel.js create mode 100644 src/channel_integrations/callr/index.js create mode 100644 src/channel_integrations/callr/test/integration.js rename src/{services/CiscoSpark.service.js => channel_integrations/cisco_spark/channel.js} (69%) create mode 100644 src/channel_integrations/cisco_spark/index.js create mode 100644 src/channel_integrations/cisco_spark/test/integration.js create mode 100644 src/channel_integrations/facebook/channel.js create mode 100644 src/channel_integrations/facebook/constants.js create mode 100644 src/channel_integrations/facebook/controller.js create mode 100644 src/channel_integrations/facebook/index.js create mode 100644 src/channel_integrations/facebook/middlewares.js create mode 100644 src/channel_integrations/facebook/routes.js create mode 100644 src/channel_integrations/facebook/sdk.js create mode 100644 src/channel_integrations/facebook/test/integration.js create mode 100644 src/channel_integrations/index.js rename src/{services/Kik.service.js => channel_integrations/kik/channel.js} (56%) create mode 100644 src/channel_integrations/kik/index.js create mode 100644 src/channel_integrations/kik/test/integration.js create mode 100644 src/channel_integrations/line/channel.js create mode 100644 src/channel_integrations/line/index.js create mode 100644 src/channel_integrations/line/test/integration.js rename src/{services/Microsoft.service.js => channel_integrations/microsoft/channel.js} (63%) create mode 100644 src/channel_integrations/microsoft/index.js create mode 100644 src/channel_integrations/microsoft/test/integration.js rename src/{utils/microsoft.js => channel_integrations/microsoft/utils.js} (90%) rename src/{services/Slack.service.js => channel_integrations/slack/channel.js} (52%) create mode 100644 src/channel_integrations/slack/index.js create mode 100644 src/channel_integrations/slack/test/integration.js create mode 100644 src/channel_integrations/slack_app/channel.js create mode 100644 src/channel_integrations/slack_app/index.js create mode 100644 src/channel_integrations/slack_app/routes.js create mode 100644 src/channel_integrations/slack_app/test/integration.js create mode 100644 src/channel_integrations/slack_app/test/routes.js create mode 100644 src/channel_integrations/telegram/channel.js create mode 100644 src/channel_integrations/telegram/index.js create mode 100644 src/channel_integrations/telegram/test/channel.js create mode 100644 src/channel_integrations/telegram/test/integration.js create mode 100644 src/channel_integrations/telegram/test/mockups.json create mode 100644 src/channel_integrations/twilio/channel.js create mode 100644 src/channel_integrations/twilio/index.js create mode 100644 src/channel_integrations/twilio/test/integration.js rename src/{services/Twitter.service.js => channel_integrations/twitter/channel.js} (58%) create mode 100644 src/channel_integrations/twitter/index.js create mode 100644 src/channel_integrations/twitter/test/integration.js create mode 100644 src/channel_integrations/webchat/channel.js create mode 100644 src/channel_integrations/webchat/index.js create mode 100644 src/channel_integrations/webchat/test/integration.js create mode 100644 src/constants/channels.js create mode 100644 src/constants/index.js delete mode 100644 src/controllers/Channels.controller.js delete mode 100644 src/controllers/Connectors.controller.js delete mode 100644 src/controllers/Conversations.controller.js delete mode 100644 src/controllers/Messages.controller.js delete mode 100644 src/controllers/Participants.controller.js delete mode 100644 src/controllers/Webhooks.controller.js rename src/controllers/{App.controller.js => application.js} (100%) create mode 100644 src/controllers/channels.js create mode 100644 src/controllers/connectors.js create mode 100644 src/controllers/conversations.js create mode 100644 src/controllers/get_started_buttons.js delete mode 100644 src/controllers/index.js create mode 100644 src/controllers/message_pipe.js create mode 100644 src/controllers/messages.js create mode 100644 src/controllers/participants.js create mode 100644 src/controllers/persistent_menus.js create mode 100644 src/controllers/webhooks.js delete mode 100644 src/models/Channel.model.js create mode 100644 src/models/channel.js rename src/models/{Connector.model.js => connector.js} (64%) rename src/models/{Conversation.model.js => conversation.js} (64%) create mode 100644 src/models/get_started_button.js rename src/models/{Message.model.js => message.js} (69%) rename src/models/{Participant.model.js => participant.js} (54%) create mode 100644 src/models/persistent_menu.js delete mode 100644 src/routes/App.routes.js delete mode 100644 src/routes/Channels.routes.js delete mode 100644 src/routes/Connectors.routes.js delete mode 100644 src/routes/Oauth.routes.js delete mode 100644 src/routes/Webhooks.routes.js create mode 100644 src/routes/application.js create mode 100644 src/routes/channels.js create mode 100644 src/routes/connectors.js rename src/routes/{Conversations.routes.js => conversations.js} (50%) create mode 100644 src/routes/get_started_buttons.js rename src/routes/{Messages.routes.js => messages.js} (81%) rename src/routes/{Participants.routes.js => participants.js} (73%) create mode 100644 src/routes/persistent_menus.js create mode 100644 src/routes/webhooks.js delete mode 100644 src/services/Callr.service.js delete mode 100644 src/services/Messenger.service.js delete mode 100644 src/services/SlackApp.service.js delete mode 100644 src/services/Telegram.service.js delete mode 100644 src/services/Template.service.js delete mode 100644 src/services/Twilio.service.js delete mode 100644 src/services/index.js delete mode 100644 src/test.js delete mode 100644 src/utils/Logger.js create mode 100644 src/utils/headers.js create mode 100644 src/utils/line.js create mode 100644 src/utils/log.js create mode 100644 src/utils/mail.js create mode 100644 src/utils/message_queue.js delete mode 100644 src/validators/Channels.validators.js create mode 100644 src/validators/channels.js rename src/validators/{Connectors.validators.js => connectors.js} (79%) create mode 100644 src/validators/index.js create mode 100644 src/validators/validators-config.js create mode 100644 test/channel_integrations/modules.js delete mode 100644 test/controllers/Bots.controller.tests.js delete mode 100644 test/controllers/Channels.controller.tests.js delete mode 100644 test/controllers/Conversations.controllers.tests.js delete mode 100644 test/controllers/Messages.controller.tests.js delete mode 100644 test/controllers/Participants.controller.tests.js create mode 100644 test/controllers/application.js create mode 100644 test/controllers/channels.js create mode 100644 test/controllers/connectors.js create mode 100644 test/controllers/conversations.js create mode 100644 test/controllers/messages.js create mode 100644 test/controllers/oauth.js create mode 100644 test/controllers/participants.js create mode 100644 test/controllers/persistent_menus.js create mode 100644 test/controllers/webhooks.js create mode 100644 test/factories/channel.js create mode 100644 test/factories/connector.js create mode 100644 test/factories/conversation.js create mode 100644 test/factories/message.js create mode 100644 test/factories/participant.js create mode 100644 test/factories/persistent_menu.js delete mode 100644 test/mocks/http.js delete mode 100644 test/models/.gitkeep delete mode 100644 test/models/Bot.model.tests.js delete mode 100644 test/models/Channel.model.tests.js delete mode 100644 test/models/Conversation.model.tests.js delete mode 100644 test/models/Participant.model.tests.js delete mode 100644 test/routes/.gitkeep delete mode 100644 test/routes/Bots.routes.tests.js delete mode 100644 test/routes/Channels.routes.tests.js delete mode 100644 test/routes/Conversations.routes.tests.js delete mode 100644 test/routes/Participants.routes.tests.js create mode 100644 test/routes/application.js create mode 100644 test/routes/channels.js create mode 100644 test/routes/connectors.js create mode 100644 test/routes/conversations.js create mode 100644 test/routes/messages.js create mode 100644 test/routes/participants.js create mode 100644 test/routes/persistent_menus.js create mode 100644 test/routes/webhooks.js delete mode 100644 test/services/Facebook.service.tests.js delete mode 100644 test/services/Kik.service.tests.js delete mode 100644 test/services/Slack.service.tests.js delete mode 100644 test/services/fetchMethod.service.js create mode 100644 test/tools/index.js create mode 100644 test/tools/integration_setup.js create mode 100644 test/util/message_queue.js create mode 100644 test/util/start_application.js delete mode 100644 test/validators/Bots.validators.tests.js delete mode 100644 test/validators/Channels.validators.tests.js delete mode 100644 test/validators/Participants.validators.tests.js delete mode 100644 test/validators/Webhooks.validators.tests.js delete mode 100644 yarn.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 79ff81b..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,48 +0,0 @@ -# Community Contributing Guide - -Contributions are always welcome, no matter how large or small, from everyone. - -If you'd like to help make Connector better, you totally rock! Here are some ways to contribute: -- by adding new messaging channels -- by reporting bugs you encounter or suggesting new features -- by improving the documentation -- by improving existing code -- by blackfilling unit tests for modules that lack coverage - -If you want to add a new channel, please check this [wiki page](https://github.com/RecastAI/bot-connector/wiki/01---Add-a-channel) for more information. - -## Guidelines - -* Tests must pass -* Follow the existing coding style -* If you fix a bug, add a test - -## Steps for Contributing - -* create an issue with the bug you want to fix, or the feature that you want to add -* create your own fork on github -* write your code in your local copy -* make the tests and lint pass -* if everything is fine, commit your changes to your fork and create a pull request from there - -## Setup - -#### Installation -```sh -$ git clone https://github.com/RecastAI/bot-connector.git -$ cd Connector -$ yarn -``` - -#### Running in development mode (hot reload) - -```sh -$ yarn start-dev -``` - -#### Testing - -```sh -$ yarn test -$ yarn lint -``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4dde146 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:carbon + +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY package*.json ./ + +RUN npm install +# If you are building your code for production +# RUN npm install --only=production + +# Bundle app source +COPY . . +RUN npm run build +EXPOSE 3004 +CMD [ "node", "dist/index.js" ] \ No newline at end of file diff --git a/ISSUE.md b/ISSUE.md deleted file mode 100644 index 4bb3328..0000000 --- a/ISSUE.md +++ /dev/null @@ -1,7 +0,0 @@ -# Troubleshooting - -If you encounter an issue with Connector, please check first if it doesn't already exist. -If it's doesn't, create an issue with the following content: -* Connector's version you're using -* the behavior you expect, and the one you see -* if possible, the step to reproduce the unwanted behavior diff --git a/LICENSE b/LICENSE index 7fe3c33..276d192 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 RecastAI +Copyright (c) 2019 SAP Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ca05206..dd8e472 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ -![Bot Conector Logo](https://cdn.recast.ai/bot-connector/bot-connector-logo.png) +![Bot Connector Logo](https://cdn.cai.tools.sap/bot-connector/bot-connector-logo.png) -| [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages-format) | [Getting Started with Recast.AI]( #getting-started-with-recastai) | [License](#license) | -|---|---|---|---|---|---| +| [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages-format) | [Getting Started with SAP Conversational AI]( #getting-started-with-sap-conversational-ai) | +|---|---|---|---|---| - +[💬 Questions / Comments? Join the discussion on our community Slack channel!](https://slack.cai.tools.sap) # Bot Connector @@ -15,47 +13,46 @@ It provides a higher level API to manage several messaging platforms at once, an ## Documentation -You can see the API documentation [here](https://recastai.github.io/bot-connector/) +You can see the API documentation [here](https://sapconversationalai.github.io/bot-connector/) Or generate the documentation with the following command: ```bash -yarn doc && open doc/index.html +yarn docs && open docs/index.html ``` ## Supported Channels Bot Connector supports the following channels: -* [Kik](https://github.com/RecastAI/bot-connector/wiki/Channel---Kik) -* [Slack](https://github.com/RecastAI/bot-connector/wiki/Channel---Slack) -* [Messenger](https://github.com/RecastAI/bot-connector/wiki/Channel---Messenger) -* [Callr](https://github.com/RecastAI/bot-connector/wiki/Channel-CALLR) -* [Telegram](https://github.com/RecastAI/bot-connector/wiki/Channel-Telegram) -* [Twilio](https://github.com/RecastAI/bot-connector/wiki/Channel-Twilio) -* [Cisco Spark](https://github.com/RecastAI/bot-connector/wiki/Channel-Cisco) + +* [Kik](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel---Kik) +* [Slack](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel---Slack) +* [Facebook Messenger](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel---Messenger) +* [Callr](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-CALLR) +* [Telegram](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Telegram) +* [Twilio](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Twilio) +* [Cisco Webex](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Cisco) * [Microsoft Bot Framework (Skype, Teams, Cortana,...)](https://github.com/RecastAI/bot-connector/wiki/Channel-Microsoft-Bot-Framework) -* [Twitter](https://github.com/RecastAI/bot-connector/wiki/Channel-Twitter) +* [Twitter](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Twitter) +* Line -You can find more information on each channel in the [wiki](https://github.com/RecastAI/bot-connector/wiki) +You can find more information on each channel in the [wiki](https://github.com/SAPConversationalAI/bot-connector/wiki) -More will be added, and you can [contribute](https://github.com/RecastAI/bot-connector/blob/master/CONTRIBUTING.md) if you want to, and add a thumbs up for the channel you want to see implemented first ;) +More will be added, and you can contribute if you want to, and add a thumbs up for the channel you want to see implemented first ;) (To do so, fork this repo, add a thumbs up and make a PR!) * Discord 👍👍👍 -* Line 👍 * Ryver 👍 * Viber * Wechat 👍👍 * Zinc.it 👍 * Salesforce 👍 -You can find the current roadmap [here](https://github.com/RecastAI/bot-connector/projects/1). - ## Getting started The following examples use [yarn](https://github.com/yarnpkg/yarn) package manager but you can use your favorite one like npm, or pnpm. -In order to run the connector you need MongoDB installed and running. The configuration files for MongoDB are stored in *config* directory. +In order to run the connector you need MongoDB and Redis installed and running. The configuration files for both are stored in *config* directory. ### Installation @@ -67,10 +64,56 @@ cd bot-connector yarn install ``` +### Available Commands + +* `yarn start` - Start application in production mode +* `yarn start:dev` - Start application in development mode +* `yarn start:dev:debug` - Start application in development mode with debugger +* `yarn test` - Run unit & integration tests +* `yarn test:debug` - Run unit & integration tests with debugger +* `yarn test:coverage` - Run unit & integration tests with coverage report +* `yarn lint` - Run ESLint +* `yarn build` - Build artifacts for production +* `yarn docs` - Generate apidoc documentation + +#### Configurations/Environments + +You need to create a configuration file based on the following schema: + +config/{env}.js + +``` +module.exports = { + db: { + host: 'localhost', + port: 27017, + dbName: 'botconnector', + }, + server: { + port: 8080, + }, + redis: { + port: 6379, + host: 'localhost', + auth: '', + db: 7, + options: {}, // see https://github.com/mranney/node_redis#rediscreateclient + }, + mail: {}, // valid object to be passed to nodemail.createTransport() + base_url: '', // base url of the connector + facebook_app_id: '', + facebook_app_secret: '', + facebook_app_webhook_token: '', + amazon_client_id: '', // Client ID for use with Login with Amazon (Amazon Alexa channel) + amazon_client_secret: '', // Client Id for use with Login with Amazon (Amazon Alexa channel) +} + +``` + #### Running in development mode (hot reload) ```bash -yarn start-dev +yarn start:dev ``` #### Setup your connector @@ -87,13 +130,13 @@ yarn install yarn start ``` -Now that your bot (well, your code) and the Bot Connector are running, you have to create channels. Channel is the actual link between your connector and a specific service like Messenger, Slack or Kik. A connector can have multiple channels. +Now that your bot (well, your code) and the Bot Connector are running, you have to create channels. A channel is the actual link between your connector and a specific service like Messenger, Slack or Kik. One connector can have multiple channels. ## How it works There are two distinct flows: -* your bot receive a message from a channel -* your bot send a message to a channel +* your bot receives a message from a channel +* your bot sends a message to a channel This pipeline allows us to have an abstraction of messages independent of the platform and implement only a few functions for each messaging platform (input and output parsing). @@ -105,7 +148,7 @@ The Bot Connector posts on your connector's endpoint each time a new message arr * the message is saved in MongoDB * the message is post to the connector endpoint -![BotConnector-Receive](https://cdn.recast.ai/bot-connector/flow-1.png) +![BotConnector-Receive](https://cdn.cai.tools.sap/bot-connector/flow-1.png) #### Post a message @@ -114,7 +157,7 @@ To send a new message, you have to post it to Bot Connector's API * the messages are formatted by the corresponding service to match the channel's format * the messages are sent by Bot Connector to the corresponding channel -![BotConnector-Sending](https://cdn.recast.ai/bot-connector/flow-2.png) +![BotConnector-Sending](https://cdn.cai.tools.sap/bot-connector/flow-2.png) ## Messages format @@ -231,41 +274,24 @@ Bot Connector supports several message formats: { title: 'BUTTON_1_TITLE', value: 'BUTTON_1_VALUE', - type: 'BUTTON_1_TYPE', - } + type: 'BUTTON_1_TYPE', + } ] } ], } ``` -### Issue - -If you encounter any issue, please follow this [guide](https://github.com/RecastAI/bot-connector/blob/master/ISSUE.md). - -### Contribution -Want to contribute? Great! Please check this [guide](https://github.com/RecastAI/bot-connector/blob/master/CONTRIBUTING.md). - -## Getting started with Recast.AI +## Getting started with SAP Conversational AI We build products to help enterprises and developers have a better understanding of user inputs. - **NLP API**: a unique API for text processing, and augmented training. -- **Bot Building Tools**: all you need to create smart bots powered by Recast.AI's NLP API. Design even the most complex conversation flow, use all rich messaging formats and connect to external APIs and services. +- **Bot Building Tools**: all you need to create smart bots powered by SAP Conversational AI's NLP API. Design even the most complex conversation flow, use all rich messaging formats and connect to external APIs and services. - **Bot Connector API**: standardizes the messaging format across all channels, letting you connect your bots to any channel in minutes. Learn more about: -| [API Documentation](https://recast.ai/docs/api-reference/) | [Discover the platform](https://recast.ai/docs/create-your-bot) | [First bot tutorial](https://recast.ai/blog/build-your-first-bot-with-recast-ai/) | [Advanced NodeJS tutorial](https://recast.ai/blog/nodejs-chatbot-movie-bot/) | [Advanced Python tutorial](https://recast.ai/blog/python-cryptobot/) | +| [API Documentation](https://cai.tools.sap/docs/api-reference/) | [Discover the platform](https://cai.tools.sap/docs/concepts/create-builder-bot) | [First bot tutorial](https://cai.tools.sap/blog/build-your-first-bot-with-recast-ai/) | [Advanced NodeJS tutorial](https://cai.tools.sap/blog/nodejs-chatbot-movie-bot/) | [Advanced Python tutorial](https://cai.tools.sap/blog/python-cryptobot/) | |---|---|---|---|---| - -### License - -Copyright (c) [2016] [Recast.AI](https://recast.ai) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/config/development.js b/config/development.js deleted file mode 100644 index 3da9faa..0000000 --- a/config/development.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - db: { - host: 'localhost', - port: '27017', - dbName: 'chatbot-connector', - }, - server: { - port: '8080', - url: 'localhost:8080', - }, - base_url: 'http://localhost:8000', -} diff --git a/config/index.js b/config/index.js index c28a07e..391f9f6 100644 --- a/config/index.js +++ b/config/index.js @@ -1,3 +1,3 @@ const env = process.env.NODE_ENV || 'development' -module.exports = require('./' + env + '.js') +module.exports = require(`./${env}.js`) diff --git a/config/production.js b/config/production.js deleted file mode 100644 index 3da9faa..0000000 --- a/config/production.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - db: { - host: 'localhost', - port: '27017', - dbName: 'chatbot-connector', - }, - server: { - port: '8080', - url: 'localhost:8080', - }, - base_url: 'http://localhost:8000', -} diff --git a/config/test.js b/config/test.js index 863f631..31998c2 100644 --- a/config/test.js +++ b/config/test.js @@ -2,9 +2,21 @@ module.exports = { db: { host: 'localhost', port: 27017, - dbName: 'chatbot-connector-test', + dbName: 'gromit-test', }, server: { - port: 8080, + port: 2424, }, + redis: { + port: 6379, + host: 'localhost', + auth: '', + db: 14, + options: {}, // see https://github.com/mranney/node_redis#rediscreateclient + }, + base_url: 'http://localhost:2424', + skillsBuilderUrl: 'https://api.cai.tools.sap/build/v1/dialog', + facebook_app_id: '1234567890123456', + facebook_app_secret: 'abcewfnjrefu340bg3', + facebook_app_webhook_token: '', } diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 8c21aab..0000000 --- a/deploy.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -rm -rf doc && \ -npm run doc && \ -cd doc && \ -git init && \ -git remote add origin git@github.com:RecastAI/bot-connector.git && \ -git add . && \ -git commit -m "deploy" && \ -git push -f origin master:gh-pages -printf '\n> everything has been done.\n' diff --git a/example/bot.js b/example/bot.js deleted file mode 100644 index 21f2317..0000000 --- a/example/bot.js +++ /dev/null @@ -1,34 +0,0 @@ -import express from 'express' -import bodyParser from 'body-parser' -import request from 'superagent' - -const app = express() -app.set('port', process.env.PORT || 5000) -app.use(bodyParser.json()) - -const config = { url: 'http://localhost:8080', botId: 'yourBotId' } - - /* Get the request from the connector */ - - app.post('/', (req, res) => { - const conversationId = req.body.message.conversation - const message = [{ - type: 'text', - content: 'my first message', - }] - - /* Send the message back to the connector */ - request.post(`${config.url}/bots/${config.botId}/conversations/${conversationId}/messages`) - .send({ messages, senderId: req.body.senderId }) - .end((err, res) => { - if (err) { - console.log(err) - } else { - console.log(res) - } - }) - }) - -app.listen(app.get('port'), () => { - console.log('Our bot is running on port', app.get('port')) -}) diff --git a/example/package.json b/example/package.json deleted file mode 100644 index bfac677..0000000 --- a/example/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "bot-example", - "version": "1.0.0", - "description": "", - "main": "bot.js", - "scripts": { - "start": "node ./node_modules/babel-cli/bin/babel-node.js bot.js" - }, - "dependencies": { - "body-parser": "^1.15.2", - "express": "^4.14.0", - "superagent": "^2.3.0" - }, - "devDependencies": { - "babel-cli": "^6.11.4", - "babel-eslint": "^6.1.2", - "babel-preset-es2015": "^6.9.0", - "babel-preset-stage-0": "^6.5.0", - "babel-register": "^6.11.5" - }, - "babel": { - "presets": [ - "es2015", - "stage-0" - ] - } -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9d65467 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13958 @@ +{ + "name": "botconnector", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/babel-types": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/babel-types/-/babel-types-7.0.1.tgz", + "integrity": "sha512-EkcOk09rjhivbovP8WreGRbXW20YRfe/qdgXOGq3it3u3aAOWDRNsQhL/XPAWFF7zhZZ+uR+nT+3b+TCkIap1w==" + }, + "@types/babylon": { + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@types/babylon/-/babylon-6.16.2.tgz", + "integrity": "sha512-+Jty46mPaWe1VAyZbfvgJM4BAdklLWxrT5tc/RjvCgLrtk6gzRY6AOnoWFv4p6hVxhJshDdr2hGVn56alBp97Q==", + "requires": { + "@types/babel-types": "*" + } + }, + "abab": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", + "integrity": "sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4=" + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", + "requires": { + "mime-types": "~2.1.18", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.5.3.tgz", + "integrity": "sha512-jd5MkIUlbbmb07nXH0DT3y7rDVtkzDi4XZOUVWAer8ajmF/DTSSbl5oNFyDOl/OXA33Bl79+ypHhl2pN20VeOQ==" + }, + "acorn-globals": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-3.1.0.tgz", + "integrity": "sha1-/YJw9x+7SZawBPqIDuXUZXOnMb8=", + "requires": { + "acorn": "^4.0.4" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=" + } + } + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "requires": { + "acorn": "^3.0.4" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=" + } + } + }, + "ajv": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=", + "requires": { + "co": "^4.6.0", + "json-stable-stringify": "^1.0.1" + } + }, + "ajv-keywords": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.tgz", + "integrity": "sha1-MU3QpLM2j609/NxU7eYXG4htrzw=" + }, + "alexa-verifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/alexa-verifier/-/alexa-verifier-1.0.0.tgz", + "integrity": "sha512-1XE/40ajf4sESuvAdacxVrxy06tkewC2sJ8qC5T/zQtGiYRsuoQjj6UkSpw7WTqG8wkmESANz/w5NnfIruyCjQ==", + "requires": { + "node-forge": "^0.7.0", + "validator": "^9.0.0" + } + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + }, + "ansi-align": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz", + "integrity": "sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=", + "dev": true, + "requires": { + "string-width": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + }, + "anymatch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", + "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", + "dev": true, + "optional": true, + "requires": { + "micromatch": "^2.1.5", + "normalize-path": "^2.0.0" + } + }, + "apidoc": { + "version": "0.17.7", + "resolved": "https://registry.npmjs.org/apidoc/-/apidoc-0.17.7.tgz", + "integrity": "sha512-9Wf4bRPwCuWOIOxR42dDnsXnFw+rhJg5VrMQK+KmNxJwyIh30UqX6gvjjXSG6YO74MqE87F18bbQXUENK9dPGg==", + "dev": true, + "requires": { + "apidoc-core": "~0.8.2", + "commander": "^2.19.0", + "fs-extra": "^7.0.0", + "lodash": "^4.17.10", + "markdown-it": "^8.3.1", + "winston": "^3.0.0" + }, + "dependencies": { + "commander": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", + "dev": true + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "winston": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.1.0.tgz", + "integrity": "sha512-FsQfEE+8YIEeuZEYhHDk5cILo1HOcWkGwvoidLrDgPog0r4bser1lEIOco2dN9zpDJ1M88hfDgZvxe5z4xNcwg==", + "dev": true, + "requires": { + "async": "^2.6.0", + "diagnostics": "^1.1.1", + "is-stream": "^1.1.0", + "logform": "^1.9.1", + "one-time": "0.0.4", + "readable-stream": "^2.3.6", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.2.0" + } + } + } + }, + "apidoc-core": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/apidoc-core/-/apidoc-core-0.8.3.tgz", + "integrity": "sha1-2dY1RYKd8lDSzKBJaDqH53U2S5Y=", + "dev": true, + "requires": { + "fs-extra": "^3.0.1", + "glob": "^7.1.1", + "iconv-lite": "^0.4.17", + "klaw-sync": "^2.1.0", + "lodash": "~4.17.4", + "semver": "~5.3.0" + }, + "dependencies": { + "fs-extra": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-3.0.1.tgz", + "integrity": "sha1-N5TzeMWLNC6n27sjCVEJxLO2IpE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^3.0.0", + "universalify": "^0.1.0" + } + }, + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "dev": true + } + } + }, + "apparatus": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/apparatus/-/apparatus-0.0.9.tgz", + "integrity": "sha1-N9zSWDStC2UQdllikduCPusZCL0=", + "optional": true, + "requires": { + "sylvester": ">= 0.0.8" + } + }, + "archiver": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-1.3.0.tgz", + "integrity": "sha1-TyGU1tj5nfP1MeaIHxTxXVX6ryI=", + "requires": { + "archiver-utils": "^1.3.0", + "async": "^2.0.0", + "buffer-crc32": "^0.2.1", + "glob": "^7.0.0", + "lodash": "^4.8.0", + "readable-stream": "^2.0.0", + "tar-stream": "^1.5.0", + "walkdir": "^0.0.11", + "zip-stream": "^1.1.0" + } + }, + "archiver-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-1.3.0.tgz", + "integrity": "sha1-5QtMCccL89aA4y/xt5lOn52JUXQ=", + "requires": { + "glob": "^7.0.0", + "graceful-fs": "^4.1.0", + "lazystream": "^1.0.0", + "lodash": "^4.8.0", + "normalize-path": "^2.0.0", + "readable-stream": "^2.0.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + } + } + }, + "arr-diff": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", + "requires": { + "arr-flatten": "^1.0.1" + } + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==" + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=" + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=" + }, + "array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=" + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=" + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "ask-sdk": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/ask-sdk/-/ask-sdk-2.0.7.tgz", + "integrity": "sha512-fJDjDv1BqTvfiwHKOgPYuQbi0HCfe320Po1S6qqhdgHN/ZHuolHRYIEe8i3O1sk/qObCsrwOD9xh0l10E48Spw==", + "requires": { + "ask-sdk-core": "^2.0.7", + "ask-sdk-dynamodb-persistence-adapter": "^2.0.7", + "ask-sdk-model": "^1.0.0" + } + }, + "ask-sdk-core": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/ask-sdk-core/-/ask-sdk-core-2.0.7.tgz", + "integrity": "sha512-L0YoF7ls0iUoo/WYDYj7uDjAFThYZSDjzF8YvLHIEZyzKVgrNNqxetRorUB+odDoctPWW7RV1xcep8F4p7c1jg==" + }, + "ask-sdk-dynamodb-persistence-adapter": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/ask-sdk-dynamodb-persistence-adapter/-/ask-sdk-dynamodb-persistence-adapter-2.0.7.tgz", + "integrity": "sha512-RHIOOAfsIwZy7hUtENPF12l1NldB8+rRZ3Jk4I9efpFFZSmXOM510Qho3Lt3OZCH9qTDRp1QWrQvn3ki7fdM8A==", + "requires": { + "aws-sdk": "^2.163.0" + } + }, + "ask-sdk-model": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ask-sdk-model/-/ask-sdk-model-1.3.1.tgz", + "integrity": "sha512-ZDcmJ8sDRAzfIPz5WhRpy8HJ8SheBOyjoeHtYIcoVO6ZQEgxXtZ11GJGg8FhRDfwwIWj5Ma8G6m7OCgo4nuJDA==" + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + }, + "assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=" + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==" + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", + "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", + "requires": { + "lodash": "^4.14.0" + } + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=", + "dev": true + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "atob": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.0.tgz", + "integrity": "sha512-SuiKH8vbsOyCALjA/+EINmt/Kdl+TQPrtFgW7XZZcwtryFu9e5kQoX3bjCW6mIvGH1fbeAZZuvwGR5IlBRznGw==", + "dev": true + }, + "aws-sdk": { + "version": "2.267.1", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.267.1.tgz", + "integrity": "sha512-4YZiZ6yk/Wb5lPTaV6AuiY7wo4f9W5TPNAx/Bempic4nbw9KmURrqwguzcY4oEl0Sq/FeDSp+AngHD+rHd4NlQ==", + "requires": { + "buffer": "4.9.1", + "events": "1.1.1", + "ieee754": "1.1.8", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.1.0", + "xml2js": "0.4.17" + }, + "dependencies": { + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "uuid": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + } + } + }, + "aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=" + }, + "aws4": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==" + }, + "babel-cli": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-cli/-/babel-cli-6.18.0.tgz", + "integrity": "sha1-khF/NBrdnerZD2+n0Kl8DMCOwYY=", + "dev": true, + "requires": { + "babel-core": "^6.18.0", + "babel-polyfill": "^6.16.0", + "babel-register": "^6.18.0", + "babel-runtime": "^6.9.0", + "chokidar": "^1.0.0", + "commander": "^2.8.1", + "convert-source-map": "^1.1.0", + "fs-readdir-recursive": "^1.0.0", + "glob": "^5.0.5", + "lodash": "^4.2.0", + "output-file-sync": "^1.1.0", + "path-is-absolute": "^1.0.0", + "slash": "^1.0.0", + "source-map": "^0.5.0", + "v8flags": "^2.0.10" + }, + "dependencies": { + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + } + }, + "babel-core": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz", + "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-generator": "^6.26.0", + "babel-helpers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-register": "^6.26.0", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "convert-source-map": "^1.5.0", + "debug": "^2.6.8", + "json5": "^0.5.1", + "lodash": "^4.17.4", + "minimatch": "^3.0.4", + "path-is-absolute": "^1.0.1", + "private": "^0.1.7", + "slash": "^1.0.0", + "source-map": "^0.5.6" + }, + "dependencies": { + "babel-generator": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", + "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "requires": { + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.17.4", + "source-map": "^0.5.7", + "trim-right": "^1.0.1" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-eslint": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-7.2.3.tgz", + "integrity": "sha1-sv4tgBJkcPXBlELcdXJTqJdxCCc=", + "dev": true, + "requires": { + "babel-code-frame": "^6.22.0", + "babel-traverse": "^6.23.1", + "babel-types": "^6.23.0", + "babylon": "^6.17.0" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-generator": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.19.0.tgz", + "integrity": "sha1-my8kQgR3ej1oEOwSfGc8h7NJ+sU=", + "dev": true, + "requires": { + "babel-messages": "^6.8.0", + "babel-runtime": "^6.9.0", + "babel-types": "^6.19.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.2.0", + "source-map": "^0.5.0" + } + }, + "babel-helper-bindify-decorators": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz", + "integrity": "sha1-FMGeXxQte0fxmlJDHlKxzLxAozA=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-builder-binary-assignment-operator-visitor": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz", + "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=", + "requires": { + "babel-helper-explode-assignable-expression": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-call-delegate": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz", + "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=", + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-define-map": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz", + "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=", + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-explode-assignable-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz", + "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-explode-class": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-class/-/babel-helper-explode-class-6.24.1.tgz", + "integrity": "sha1-fcKjkQ3uAHBW4eMdZAztPVTqqes=", + "requires": { + "babel-helper-bindify-decorators": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "requires": { + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-hoist-variables": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz", + "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-optimise-call-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz", + "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-regex": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz", + "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=", + "requires": { + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-remap-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz", + "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=", + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helper-replace-supers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz", + "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=", + "requires": { + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-check-es2015-constants": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz", + "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-istanbul": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz", + "integrity": "sha512-PWP9FQ1AhZhS01T/4qLSKoHGY/xvkZdVBGlKM/HuxxS3+sC66HhTNR7+MpbO/so/cz/wY94MeSWJuP1hXIPfwQ==", + "dev": true, + "requires": { + "babel-plugin-syntax-object-rest-spread": "^6.13.0", + "find-up": "^2.1.0", + "istanbul-lib-instrument": "^1.10.1", + "test-exclude": "^4.2.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "test-exclude": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-4.2.1.tgz", + "integrity": "sha512-qpqlP/8Zl+sosLxBcVKl9vYy26T9NPalxSzzCP/OY6K7j938ui2oKgo+kRZYfxAeIpLqpbVnsHq1tyV70E4lWQ==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "micromatch": "^3.1.8", + "object-assign": "^4.1.0", + "read-pkg-up": "^1.0.1", + "require-main-filename": "^1.0.1" + } + } + } + }, + "babel-plugin-syntax-async-functions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", + "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=" + }, + "babel-plugin-syntax-async-generators": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz", + "integrity": "sha1-a8lj67FuzLrmuStZbrfzXDQqi5o=" + }, + "babel-plugin-syntax-class-constructor-call": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-constructor-call/-/babel-plugin-syntax-class-constructor-call-6.18.0.tgz", + "integrity": "sha1-nLnTn+Q8hgC+yBRkVt3L1OGnZBY=" + }, + "babel-plugin-syntax-class-properties": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz", + "integrity": "sha1-1+sjt5oxf4VDlixQW4J8fWysJ94=" + }, + "babel-plugin-syntax-decorators": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz", + "integrity": "sha1-MSVjtNvePMgGzuPkFszurd0RrAs=" + }, + "babel-plugin-syntax-do-expressions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-do-expressions/-/babel-plugin-syntax-do-expressions-6.13.0.tgz", + "integrity": "sha1-V0d1YTmqJtOQ0JQQsDdEugfkeW0=" + }, + "babel-plugin-syntax-dynamic-import": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz", + "integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo=" + }, + "babel-plugin-syntax-exponentiation-operator": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz", + "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=" + }, + "babel-plugin-syntax-export-extensions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-export-extensions/-/babel-plugin-syntax-export-extensions-6.13.0.tgz", + "integrity": "sha1-cKFITw+QiaToStRLrDU8lbmxJyE=" + }, + "babel-plugin-syntax-function-bind": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-function-bind/-/babel-plugin-syntax-function-bind-6.13.0.tgz", + "integrity": "sha1-SMSV8Xe98xqYHnMvVa3AvdJgH0Y=" + }, + "babel-plugin-syntax-object-rest-spread": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", + "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=" + }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=" + }, + "babel-plugin-transform-async-generator-functions": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.24.1.tgz", + "integrity": "sha1-8FiQAUX9PpkHpt3yjaWfIVJYpds=", + "requires": { + "babel-helper-remap-async-to-generator": "^6.24.1", + "babel-plugin-syntax-async-generators": "^6.5.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz", + "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=", + "requires": { + "babel-helper-remap-async-to-generator": "^6.24.1", + "babel-plugin-syntax-async-functions": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-class-constructor-call": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-constructor-call/-/babel-plugin-transform-class-constructor-call-6.24.1.tgz", + "integrity": "sha1-gNwoVQWsBn3LjWxl4vbxGrd2Xvk=", + "requires": { + "babel-plugin-syntax-class-constructor-call": "^6.18.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-class-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz", + "integrity": "sha1-anl2PqYdM9NvN7YRqp3vgagbRqw=", + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-plugin-syntax-class-properties": "^6.8.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-decorators": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.24.1.tgz", + "integrity": "sha1-eIAT2PjGtSIr33s0Q5Df13Vp4k0=", + "requires": { + "babel-helper-explode-class": "^6.24.1", + "babel-plugin-syntax-decorators": "^6.13.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-do-expressions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-do-expressions/-/babel-plugin-transform-do-expressions-6.22.0.tgz", + "integrity": "sha1-KMyvkoEtlJws0SgfaQyP3EaK6bs=", + "requires": { + "babel-plugin-syntax-do-expressions": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-arrow-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz", + "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoped-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz", + "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoping": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz", + "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=", + "requires": { + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-classes": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz", + "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=", + "requires": { + "babel-helper-define-map": "^6.24.1", + "babel-helper-function-name": "^6.24.1", + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-helper-replace-supers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-computed-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz", + "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-destructuring": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz", + "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-duplicate-keys": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz", + "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-for-of": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz", + "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz", + "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=", + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz", + "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-modules-amd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz", + "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=", + "requires": { + "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-commonjs": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz", + "integrity": "sha1-DYOUApt9xqvhqX7xgeAHWN0uXYo=", + "requires": { + "babel-plugin-transform-strict-mode": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-types": "^6.26.0" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-modules-systemjs": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz", + "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=", + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-umd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz", + "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=", + "requires": { + "babel-plugin-transform-es2015-modules-amd": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-object-super": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz", + "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=", + "requires": { + "babel-helper-replace-supers": "^6.24.1", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-parameters": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz", + "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=", + "requires": { + "babel-helper-call-delegate": "^6.24.1", + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-shorthand-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz", + "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-spread": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz", + "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-sticky-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz", + "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=", + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-plugin-transform-es2015-template-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz", + "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-typeof-symbol": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz", + "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=", + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-unicode-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz", + "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=", + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "regexpu-core": "^2.0.0" + } + }, + "babel-plugin-transform-exponentiation-operator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz", + "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", + "requires": { + "babel-helper-builder-binary-assignment-operator-visitor": "^6.24.1", + "babel-plugin-syntax-exponentiation-operator": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-export-extensions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-export-extensions/-/babel-plugin-transform-export-extensions-6.22.0.tgz", + "integrity": "sha1-U3OLR+deghhYnuqUbLvTkQm75lM=", + "requires": { + "babel-plugin-syntax-export-extensions": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-function-bind": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-function-bind/-/babel-plugin-transform-function-bind-6.22.0.tgz", + "integrity": "sha1-xvuOlqwpajELjPjqQBRiQH3fapc=", + "requires": { + "babel-plugin-syntax-function-bind": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-object-rest-spread": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz", + "integrity": "sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY=", + "requires": { + "babel-plugin-syntax-object-rest-spread": "^6.8.0", + "babel-runtime": "^6.26.0" + } + }, + "babel-plugin-transform-regenerator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", + "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=", + "requires": { + "regenerator-transform": "^0.10.0" + } + }, + "babel-plugin-transform-runtime": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-runtime/-/babel-plugin-transform-runtime-6.23.0.tgz", + "integrity": "sha1-iEkNRGUC6puOfvsP4J7E2ZR5se4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-strict-mode": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz", + "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=", + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-polyfill": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", + "integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "regenerator-runtime": "^0.10.5" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=", + "dev": true + } + } + }, + "babel-preset-env": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.6.1.tgz", + "integrity": "sha512-W6VIyA6Ch9ePMI7VptNn2wBM6dbG0eSz25HEiL40nQXCsXGTGZSTZu1Iap+cj3Q0S5a7T9+529l/5Bkvd+afNA==", + "requires": { + "babel-plugin-check-es2015-constants": "^6.22.0", + "babel-plugin-syntax-trailing-function-commas": "^6.22.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-plugin-transform-es2015-arrow-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoping": "^6.23.0", + "babel-plugin-transform-es2015-classes": "^6.23.0", + "babel-plugin-transform-es2015-computed-properties": "^6.22.0", + "babel-plugin-transform-es2015-destructuring": "^6.23.0", + "babel-plugin-transform-es2015-duplicate-keys": "^6.22.0", + "babel-plugin-transform-es2015-for-of": "^6.23.0", + "babel-plugin-transform-es2015-function-name": "^6.22.0", + "babel-plugin-transform-es2015-literals": "^6.22.0", + "babel-plugin-transform-es2015-modules-amd": "^6.22.0", + "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-systemjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-umd": "^6.23.0", + "babel-plugin-transform-es2015-object-super": "^6.22.0", + "babel-plugin-transform-es2015-parameters": "^6.23.0", + "babel-plugin-transform-es2015-shorthand-properties": "^6.22.0", + "babel-plugin-transform-es2015-spread": "^6.22.0", + "babel-plugin-transform-es2015-sticky-regex": "^6.22.0", + "babel-plugin-transform-es2015-template-literals": "^6.22.0", + "babel-plugin-transform-es2015-typeof-symbol": "^6.23.0", + "babel-plugin-transform-es2015-unicode-regex": "^6.22.0", + "babel-plugin-transform-exponentiation-operator": "^6.22.0", + "babel-plugin-transform-regenerator": "^6.22.0", + "browserslist": "^2.1.2", + "invariant": "^2.2.2", + "semver": "^5.3.0" + } + }, + "babel-preset-stage-0": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-stage-0/-/babel-preset-stage-0-6.24.1.tgz", + "integrity": "sha1-VkLRUEL5E4TX5a+LyIsduVsDnmo=", + "requires": { + "babel-plugin-transform-do-expressions": "^6.22.0", + "babel-plugin-transform-function-bind": "^6.22.0", + "babel-preset-stage-1": "^6.24.1" + } + }, + "babel-preset-stage-1": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-stage-1/-/babel-preset-stage-1-6.24.1.tgz", + "integrity": "sha1-dpLNfc1oSZB+auSgqFWJz7niv7A=", + "requires": { + "babel-plugin-transform-class-constructor-call": "^6.24.1", + "babel-plugin-transform-export-extensions": "^6.22.0", + "babel-preset-stage-2": "^6.24.1" + } + }, + "babel-preset-stage-2": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-stage-2/-/babel-preset-stage-2-6.24.1.tgz", + "integrity": "sha1-2eKWD7PXEYfw5k7sYrwHdnIZvcE=", + "requires": { + "babel-plugin-syntax-dynamic-import": "^6.18.0", + "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-decorators": "^6.24.1", + "babel-preset-stage-3": "^6.24.1" + } + }, + "babel-preset-stage-3": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-stage-3/-/babel-preset-stage-3-6.24.1.tgz", + "integrity": "sha1-g2raCp56f6N8sTj7kyb4eTSkg5U=", + "requires": { + "babel-plugin-syntax-trailing-function-commas": "^6.22.0", + "babel-plugin-transform-async-generator-functions": "^6.24.1", + "babel-plugin-transform-async-to-generator": "^6.24.1", + "babel-plugin-transform-exponentiation-operator": "^6.24.1", + "babel-plugin-transform-object-rest-spread": "^6.22.0" + } + }, + "babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "requires": { + "babel-core": "^6.26.0", + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "home-or-tmp": "^2.0.0", + "lodash": "^4.17.4", + "mkdirp": "^0.5.1", + "source-map-support": "^0.4.15" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "requires": { + "babel-runtime": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "lodash": "^4.17.4" + }, + "dependencies": { + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "babel-traverse": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.19.0.tgz", + "integrity": "sha1-aDY/uCHiYkfVKlGahLLOq4309Vo=", + "dev": true, + "requires": { + "babel-code-frame": "^6.16.0", + "babel-messages": "^6.8.0", + "babel-runtime": "^6.9.0", + "babel-types": "^6.19.0", + "babylon": "^6.11.0", + "debug": "^2.2.0", + "globals": "^9.0.0", + "invariant": "^2.2.0", + "lodash": "^4.2.0" + } + }, + "babel-types": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.19.0.tgz", + "integrity": "sha1-jbKXLb7QHxGSqLYCuh4eTFFiQLk=", + "dev": true, + "requires": { + "babel-runtime": "^6.9.1", + "esutils": "^2.0.2", + "lodash": "^4.2.0", + "to-fast-properties": "^1.0.1" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "base64url": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-2.0.0.tgz", + "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=" + }, + "basic-auth": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", + "integrity": "sha1-AV2z81PgLlY3d1X5YnQuiYHnu7o=", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bhttp": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/bhttp/-/bhttp-1.2.4.tgz", + "integrity": "sha1-/tDCT3ZbNa/ElAsIqzIUgT44848=", + "requires": { + "bluebird": "^2.8.2", + "concat-stream": "^1.4.7", + "debug": "^2.1.1", + "dev-null": "^0.1.1", + "errors": "^0.2.0", + "extend": "^2.0.0", + "form-data2": "^1.0.0", + "form-fix-array": "^1.0.0", + "lodash": "^2.4.1", + "stream-length": "^1.0.2", + "string": "^3.0.0", + "through2-sink": "^1.0.0", + "through2-spy": "^1.2.0", + "tough-cookie": "^2.3.1" + }, + "dependencies": { + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + }, + "extend": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-2.0.1.tgz", + "integrity": "sha1-HugBBonnOV/5RIJByYZSvHWagmA=" + }, + "lodash": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz", + "integrity": "sha1-+t2DS5aDBz2hebPq5tnA0VBT9z4=" + } + } + }, + "binary-extensions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.11.0.tgz", + "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=", + "dev": true + }, + "bl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + }, + "blueimp-md5": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.10.0.tgz", + "integrity": "sha512-EkNUOi7tpV68TqjpiUz9D9NcT8um2+qtgntmMbi5UKssVX2m/2PLqotcric0RE63pB3HPN/fjf3cKHN2ufGSUQ==" + }, + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.1", + "http-errors": "~1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "~2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "~1.6.15" + }, + "dependencies": { + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + } + } + }, + "boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", + "requires": { + "hoek": "2.x.x" + } + }, + "botbuilder": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/botbuilder/-/botbuilder-3.12.0.tgz", + "integrity": "sha1-G2BdUbN2WdTJsKPdi/IQV4jBfR0=", + "requires": { + "async": "^1.5.2", + "base64url": "^2.0.0", + "chrono-node": "^1.1.3", + "jsonwebtoken": "^7.0.1", + "promise": "^7.1.1", + "request": "^2.69.0", + "rsa-pem-from-mod-exp": "^0.8.4", + "sprintf-js": "^1.0.3", + "url-join": "^1.1.0" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "jsonwebtoken": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz", + "integrity": "sha1-d/UCHeBYtgWheD+hKD6ZgS5kVjg=", + "requires": { + "joi": "^6.10.1", + "jws": "^3.1.4", + "lodash.once": "^4.0.0", + "ms": "^2.0.0", + "xtend": "^4.0.1" + } + } + } + }, + "boxen": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz", + "integrity": "sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==", + "dev": true, + "requires": { + "ansi-align": "^2.0.0", + "camelcase": "^4.0.0", + "chalk": "^2.0.1", + "cli-boxes": "^1.0.0", + "string-width": "^2.0.0", + "term-size": "^1.2.0", + "widest-line": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", + "requires": { + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" + } + }, + "browser-process-hrtime": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.2.tgz", + "integrity": "sha1-Ql1opY00R/AqBKqJQYf86K+Le44=" + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==" + }, + "browserslist": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.11.3.tgz", + "integrity": "sha512-yWu5cXT7Av6mVwzWc8lMsJMHWn4xyjSuGYi4IozbVTLUOEYPSagUB8kiMDUHA1fS3zjr8nkxkn9jdvug4BBRmA==", + "requires": { + "caniuse-lite": "^1.0.30000792", + "electron-to-chromium": "^1.3.30" + } + }, + "bson": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.0.6.tgz", + "integrity": "sha512-D8zmlb46xfuK2gGvKmUjIklQEouN2nQ0LEHHeZ/NoHM2LDiMk2EYzZ5Ntw/Urk+bgMDosOZxaRzXxvhI5TcAVQ==" + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "buffer-from": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.0.0.tgz", + "integrity": "sha512-83apNb8KK0Se60UE1+4Ukbe3HbfELJ6UlI4ldtOGs7So4KD26orJM8hIY9lxdzP+UpItH1Yh/Y8GUvNFWFFRxA==" + }, + "buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=" + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=" + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "requires": { + "callsites": "^0.2.0" + } + }, + "callr": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/callr/-/callr-1.2.0.tgz", + "integrity": "sha1-3ss9PoejvQegGgbBEMxOjfQ7Z2M=" + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=" + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=" + }, + "caniuse-lite": { + "version": "1.0.30000830", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000830.tgz", + "integrity": "sha512-yMqGkujkoOIZfvOYiWdqPALgY/PVGiqCHUJb6yNq7xhI/pR+gQO0U2K6lRDqAiJv4+CIU3CtTLblNGw0QGnr6g==" + }, + "capture-stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz", + "integrity": "sha1-Sm+gc5nCa7pH8LJJa00PtAjFVQ0=", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", + "requires": { + "assertion-error": "^1.0.1", + "check-error": "^1.0.1", + "deep-eql": "^3.0.0", + "get-func-name": "^2.0.0", + "pathval": "^1.0.0", + "type-detect": "^4.0.0" + } + }, + "chai-http": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/chai-http/-/chai-http-4.0.0.tgz", + "integrity": "sha512-R30Lj3JHHPhknOyurh09ZEBgyO4iSSeTjbLmyLvTr88IFC+zwRjAmaxBwj9TbEAGi0IV2uW+RHaTxeah5rdSaQ==", + "requires": { + "cookiejar": "^2.1.1", + "is-ip": "^2.0.0", + "methods": "^1.1.2", + "qs": "^6.5.1", + "superagent": "^3.7.0" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=", + "requires": { + "is-regex": "^1.0.3" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=" + }, + "chokidar": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", + "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=", + "dev": true, + "optional": true, + "requires": { + "anymatch": "^1.3.0", + "async-each": "^1.0.0", + "fsevents": "^1.0.0", + "glob-parent": "^2.0.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^2.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0" + } + }, + "chrono-node": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-1.3.5.tgz", + "integrity": "sha1-oklSmKMtqCvMAa2b59d++l4kQSI=", + "requires": { + "moment": "^2.10.3" + } + }, + "ci-info": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.1.3.tgz", + "integrity": "sha512-SK/846h/Rcy8q9Z9CAwGBLfCJ6EkjJWdpelWDufQpqVDYq2Wnnv8zlSO6AMQap02jvhVruKKpEtQOufo3pFhLg==", + "dev": true + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==" + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "clean-css": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.11.tgz", + "integrity": "sha1-Ls3xRaujj1R0DybO/Q/z4D4SXWo=", + "requires": { + "source-map": "0.5.x" + } + }, + "cli-boxes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz", + "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=", + "dev": true + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "requires": { + "restore-cursor": "^1.0.1" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=" + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=" + } + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", + "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, + "color-convert": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz", + "integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==", + "dev": true, + "requires": { + "color-name": "1.1.1" + } + }, + "color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=", + "dev": true + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colornames": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz", + "integrity": "sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=", + "dev": true + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + }, + "colorspace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.1.tgz", + "integrity": "sha512-pI3btWyiuz7Ken0BWh9Elzsmv2bM9AhA7psXib4anUXy/orfZ/E0MbQwhSOG/9L8hLlalqrU0UhOuqxW1YjmVw==", + "dev": true, + "requires": { + "color": "3.0.x", + "text-hex": "1.0.x" + } + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "combined-stream2": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/combined-stream2/-/combined-stream2-1.1.2.tgz", + "integrity": "sha1-9uFLegFWZvjHsKH6xQYkAWSsNXA=", + "requires": { + "bluebird": "^2.8.1", + "debug": "^2.1.1", + "stream-length": "^1.0.1" + }, + "dependencies": { + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + } + } + }, + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "compress-commons": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-1.2.2.tgz", + "integrity": "sha1-UkqfEJA/OoEzibAiXSfEi7dRiQ8=", + "requires": { + "buffer-crc32": "^0.2.1", + "crc32-stream": "^2.0.0", + "normalize-path": "^2.0.0", + "readable-stream": "^2.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "configstore": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz", + "integrity": "sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==", + "dev": true, + "requires": { + "dot-prop": "^4.1.0", + "graceful-fs": "^4.1.2", + "make-dir": "^1.0.0", + "unique-string": "^1.0.0", + "write-file-atomic": "^2.0.0", + "xdg-basedir": "^3.0.0" + } + }, + "constantinople": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-3.1.2.tgz", + "integrity": "sha512-yePcBqEFhLOqSBtwYOGGS1exHo/s1xjekXiinh4itpNQGCu4KA1euPh1fg07N2wMITZXQkBz75Ntdt1ctGZouw==", + "requires": { + "@types/babel-types": "^7.0.0", + "@types/babylon": "^6.16.2", + "babel-types": "^6.26.0", + "babylon": "^6.18.0" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "convert-source-map": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz", + "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha1-Qa1XsbVVlR7BcUEqgZQrHoIA00o=" + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-js": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.5.tgz", + "integrity": "sha1-sU3ek2xkDAV5prUMq8wTLdYSfjs=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "crc": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.5.0.tgz", + "integrity": "sha1-mLi6fUiWZbo5efWbITgTdBAaGWQ=" + }, + "crc32-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-2.0.0.tgz", + "integrity": "sha1-483TtN8xaN10494/u8t7KX/pCPQ=", + "requires": { + "crc": "^3.4.4", + "readable-stream": "^2.0.0" + } + }, + "create-error-class": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz", + "integrity": "sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=", + "dev": true, + "requires": { + "capture-stack-trace": "^1.0.0" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + } + } + }, + "cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", + "requires": { + "boom": "2.x.x" + } + }, + "crypto": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-0.0.3.tgz", + "integrity": "sha1-RwqBuGvkxe4XrMggeh9TFa4g27A=" + }, + "crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", + "dev": true + }, + "css-parse": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-1.7.0.tgz", + "integrity": "sha1-Mh9s9zeCpv91ERE5D8BeLGV9jJs=" + }, + "cssom": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.2.tgz", + "integrity": "sha1-uANhcMefB6kP8vFuIihAJ6JDhIs=" + }, + "cssstyle": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz", + "integrity": "sha1-VBCXI0yyUTyDzu06zdwn/yeYfVQ=", + "requires": { + "cssom": "0.3.x" + } + }, + "cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" + }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "requires": { + "es5-ext": "^0.10.9" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "data-urls": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.0.0.tgz", + "integrity": "sha512-ai40PPQR0Fn1lD2PPie79CibnlMN2AYiDhwFX/rZHVsxbs5kNJSjegqXIprhouGXlRdEnfybva7kqRGnB6mypA==", + "requires": { + "abab": "^1.0.4", + "whatwg-mimetype": "^2.0.0", + "whatwg-url": "^6.4.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "requires": { + "type-detect": "^4.0.0" + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "requires": { + "repeating": "^2.0.0" + } + }, + "dev-null": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", + "integrity": "sha1-WiBc48Ky73e2I41roXnrdMag6Bg=" + }, + "diagnostics": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", + "integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==", + "dev": true, + "requires": { + "colorspace": "1.1.x", + "enabled": "1.0.x", + "kuler": "1.0.x" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "requires": { + "esutils": "^2.0.2" + } + }, + "doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk=" + }, + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "requires": { + "webidl-conversions": "^4.0.2" + } + }, + "dot-prop": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", + "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==", + "dev": true, + "requires": { + "is-obj": "^1.0.0" + } + }, + "double-ended-queue": { + "version": "2.1.0-0", + "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz", + "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=" + }, + "duplexer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", + "dev": true + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz", + "integrity": "sha1-S8kmJ07Dtau1AW5+HWCSGsJisqE=", + "requires": { + "base64url": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "electron-to-chromium": { + "version": "1.3.42", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.42.tgz", + "integrity": "sha1-lcM78B0MxAVVauyJn+Yf1NduoPk=" + }, + "enabled": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", + "dev": true, + "requires": { + "env-variable": "0.0.x" + } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "requires": { + "once": "^1.4.0" + } + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true + }, + "env-variable": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.5.tgz", + "integrity": "sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==", + "dev": true + }, + "error-ex": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", + "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=", + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "errors": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/errors/-/errors-0.2.0.tgz", + "integrity": "sha1-D1Hoidqj4RsZ5xhtEfEEqmbrJAM=" + }, + "es5-ext": { + "version": "0.10.42", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.42.tgz", + "integrity": "sha512-AJxO1rmPe1bDEfSR6TJ/FgMFYuTBhR5R57KW58iCkYACMyFbrkqVyzXSurYoScDGvgyMpk7uRF/lPUPPTmsRSA==", + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.1", + "next-tick": "1" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-map": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz", + "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-set": "~0.1.5", + "es6-symbol": "~3.1.1", + "event-emitter": "~0.3.5" + } + }, + "es6-promise": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz", + "integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q=" + }, + "es6-set": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", + "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-symbol": "3.1.1", + "event-emitter": "~0.3.5" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "es6-weak-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", + "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "requires": { + "d": "1", + "es5-ext": "^0.10.14", + "es6-iterator": "^2.0.1", + "es6-symbol": "^3.1.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "escodegen": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.9.1.tgz", + "integrity": "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q==", + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + } + } + }, + "escope": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz", + "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=", + "requires": { + "es6-map": "^0.1.3", + "es6-weak-map": "^2.0.1", + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-3.19.0.tgz", + "integrity": "sha1-yPxiAcf0DdCJQbh8CFdnOGpnmsw=", + "requires": { + "babel-code-frame": "^6.16.0", + "chalk": "^1.1.3", + "concat-stream": "^1.5.2", + "debug": "^2.1.1", + "doctrine": "^2.0.0", + "escope": "^3.6.0", + "espree": "^3.4.0", + "esquery": "^1.0.0", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "glob": "^7.0.3", + "globals": "^9.14.0", + "ignore": "^3.2.0", + "imurmurhash": "^0.1.4", + "inquirer": "^0.12.0", + "is-my-json-valid": "^2.10.0", + "is-resolvable": "^1.0.0", + "js-yaml": "^3.5.1", + "json-stable-stringify": "^1.0.0", + "levn": "^0.3.0", + "lodash": "^4.0.0", + "mkdirp": "^0.5.0", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.1", + "pluralize": "^1.2.1", + "progress": "^1.1.8", + "require-uncached": "^1.0.2", + "shelljs": "^0.7.5", + "strip-bom": "^3.0.0", + "strip-json-comments": "~2.0.1", + "table": "^3.7.8", + "text-table": "~0.2.0", + "user-home": "^2.0.0" + } + }, + "eslint-config-zavatta": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/eslint-config-zavatta/-/eslint-config-zavatta-4.4.2.tgz", + "integrity": "sha1-EKM7xm7zxtT07SD1mD8iUCmTfAk=" + }, + "espree": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "requires": { + "acorn": "^5.5.0", + "acorn-jsx": "^3.0.0" + } + }, + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==" + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=" + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "event-stream": { + "version": "3.3.4", + "resolved": "http://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", + "dev": true, + "requires": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=" + }, + "expand-brackets": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", + "requires": { + "is-posix-bracket": "^0.1.0" + } + }, + "expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", + "requires": { + "fill-range": "^2.1.0" + } + }, + "expect.js": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/expect.js/-/expect.js-0.3.1.tgz", + "integrity": "sha1-sKWaDS7/VDdUTr8M6qYBWEHQm1s=", + "dev": true + }, + "express": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz", + "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.2", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.3", + "qs": "6.5.1", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.1", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + }, + "extglob": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", + "requires": { + "is-extglob": "^1.0.0" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=" + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + }, + "fast-safe-stringify": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz", + "integrity": "sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==", + "dev": true + }, + "fbgraph": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/fbgraph/-/fbgraph-1.4.4.tgz", + "integrity": "sha1-m/RcZmYVKTjCWSF0OFxVQGCPzEo=", + "requires": { + "qs": "^6.5.0", + "request": "^2.79.0" + } + }, + "fecha": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==", + "dev": true + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "requires": { + "flat-cache": "^1.2.1", + "object-assign": "^4.0.1" + } + }, + "file-type": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", + "integrity": "sha1-G2AOX8ofvcboDApwxxyNul95BsU=" + }, + "filename-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=" + }, + "fill-range": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz", + "integrity": "sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM=", + "requires": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^1.1.3", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + } + }, + "filter-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-keys/-/filter-keys-1.1.0.tgz", + "integrity": "sha1-44UVQckkaVZG+MH8TcrJEZOy53s=", + "requires": { + "micromatch": "^2.2.0" + } + }, + "filter-object": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/filter-object/-/filter-object-2.1.0.tgz", + "integrity": "sha1-r5wK0LtAoAaUa4S02zPDrl6T34Y=", + "requires": { + "extend-shallow": "^2.0.1", + "filter-keys": "^1.0.2", + "filter-values": "^0.4.0", + "kind-of": "^2.0.1", + "object.pick": "^1.1.1" + } + }, + "filter-values": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/filter-values/-/filter-values-0.4.1.tgz", + "integrity": "sha1-Webb1dP9YwK9LbFcKOcbwWEO6Es=", + "requires": { + "for-own": "^0.1.3", + "is-match": "^0.4.0" + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "requires": { + "circular-json": "^0.3.1", + "del": "^2.0.2", + "graceful-fs": "^4.1.2", + "write": "^0.2.1" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" + }, + "for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", + "requires": { + "for-in": "^1.0.1" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + } + }, + "form-data2": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/form-data2/-/form-data2-1.0.3.tgz", + "integrity": "sha1-y6XiNgGmlE2Vq31xEf+Tl6XLKk0=", + "requires": { + "bluebird": "^2.8.2", + "combined-stream2": "^1.0.2", + "debug": "^2.1.1", + "lodash": "^2.4.1", + "mime": "^1.2.11", + "uuid": "^2.0.1" + }, + "dependencies": { + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + }, + "lodash": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz", + "integrity": "sha1-+t2DS5aDBz2hebPq5tnA0VBT9z4=" + }, + "uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" + } + } + }, + "form-fix-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/form-fix-array/-/form-fix-array-1.0.0.tgz", + "integrity": "sha1-oTR6R+UxF6t7zb8+Lz7JHGZ2m8g=" + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=", + "dev": true + }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "dependencies": { + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + } + } + }, + "fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz", + "integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.3.0", + "node-pre-gyp": "^0.6.39" + }, + "dependencies": { + "abbrev": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "ajv": { + "version": "4.11.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "co": "^4.6.0", + "json-stable-stringify": "^1.0.1" + } + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "asn1": { + "version": "0.2.3", + "bundled": true, + "dev": true, + "optional": true + }, + "assert-plus": { + "version": "0.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "asynckit": { + "version": "0.4.0", + "bundled": true, + "dev": true, + "optional": true + }, + "aws-sign2": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "aws4": { + "version": "1.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "balanced-match": { + "version": "0.4.2", + "bundled": true, + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "block-stream": { + "version": "0.0.9", + "bundled": true, + "dev": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "boom": { + "version": "2.10.1", + "bundled": true, + "dev": true, + "requires": { + "hoek": "2.x.x" + } + }, + "brace-expansion": { + "version": "1.1.7", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^0.4.1", + "concat-map": "0.0.1" + } + }, + "buffer-shims": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "caseless": { + "version": "0.12.0", + "bundled": true, + "dev": true, + "optional": true + }, + "co": { + "version": "4.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "combined-stream": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "cryptiles": { + "version": "2.0.5", + "bundled": true, + "dev": true, + "requires": { + "boom": "2.x.x" + } + }, + "dashdash": { + "version": "1.14.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "debug": { + "version": "2.6.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.4.2", + "bundled": true, + "dev": true, + "optional": true + }, + "delayed-stream": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "ecc-jsbn": { + "version": "0.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "extend": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "extsprintf": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "bundled": true, + "dev": true, + "optional": true + }, + "form-data": { + "version": "2.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "fstream": { + "version": "1.0.11", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "fstream-ignore": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fstream": "^1.0.0", + "inherits": "2", + "minimatch": "^3.0.0" + } + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "getpass": { + "version": "0.1.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true, + "dev": true + }, + "har-schema": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "optional": true + }, + "har-validator": { + "version": "4.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ajv": "^4.9.1", + "har-schema": "^1.0.5" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "hawk": { + "version": "3.1.3", + "bundled": true, + "dev": true, + "requires": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + } + }, + "hoek": { + "version": "2.16.3", + "bundled": true, + "dev": true + }, + "http-signature": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.4", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "isstream": { + "version": "0.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "jodid25519": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "jsbn": { + "version": "0.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "bundled": true, + "dev": true, + "optional": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "jsonify": "~0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "jsonify": { + "version": "0.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "jsprim": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.0.2", + "json-schema": "0.2.3", + "verror": "1.3.6" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "mime-db": { + "version": "1.27.0", + "bundled": true, + "dev": true + }, + "mime-types": { + "version": "2.1.15", + "bundled": true, + "dev": true, + "requires": { + "mime-db": "~1.27.0" + } + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "node-pre-gyp": { + "version": "0.6.39", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "hawk": "3.1.3", + "mkdirp": "^0.5.1", + "nopt": "^4.0.1", + "npmlog": "^4.0.2", + "rc": "^1.1.7", + "request": "2.81.0", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^2.2.1", + "tar-pack": "^3.4.0" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npmlog": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "oauth-sign": { + "version": "0.8.2", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "performance-now": { + "version": "0.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "1.0.7", + "bundled": true, + "dev": true + }, + "punycode": { + "version": "1.4.1", + "bundled": true, + "dev": true, + "optional": true + }, + "qs": { + "version": "6.4.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "~0.4.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.2.9", + "bundled": true, + "dev": true, + "requires": { + "buffer-shims": "~1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~1.0.0", + "util-deprecate": "~1.0.1" + } + }, + "request": { + "version": "2.81.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~4.2.1", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "performance-now": "^0.2.0", + "qs": "~6.4.0", + "safe-buffer": "^5.0.1", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.0.0" + } + }, + "rimraf": { + "version": "2.6.1", + "bundled": true, + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-buffer": { + "version": "5.0.1", + "bundled": true, + "dev": true + }, + "semver": { + "version": "5.3.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sntp": { + "version": "1.0.9", + "bundled": true, + "dev": true, + "requires": { + "hoek": "2.x.x" + } + }, + "sshpk": { + "version": "1.13.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jodid25519": "^1.0.0", + "jsbn": "~0.1.0", + "tweetnacl": "~0.14.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "stringstream": { + "version": "0.0.5", + "bundled": true, + "dev": true, + "optional": true + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "2.2.1", + "bundled": true, + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.2", + "inherits": "2" + } + }, + "tar-pack": { + "version": "3.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.2.0", + "fstream": "^1.0.10", + "fstream-ignore": "^1.0.5", + "once": "^1.3.3", + "readable-stream": "^2.1.4", + "rimraf": "^2.5.1", + "tar": "^2.2.1", + "uid-number": "^0.0.6" + } + }, + "tough-cookie": { + "version": "2.3.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "punycode": "^1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "bundled": true, + "dev": true, + "optional": true + }, + "uid-number": { + "version": "0.0.6", + "bundled": true, + "dev": true, + "optional": true + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "uuid": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "verror": { + "version": "1.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "extsprintf": "1.0.2" + } + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + } + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "generate-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", + "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=" + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "requires": { + "is-property": "^1.0.0" + } + }, + "get-caller-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", + "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=" + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=" + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", + "requires": { + "glob-parent": "^2.0.0", + "is-glob": "^2.0.0" + } + }, + "glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", + "requires": { + "is-glob": "^2.0.0" + } + }, + "global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "dev": true, + "requires": { + "ini": "^1.3.4" + } + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==" + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "got": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", + "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", + "dev": true, + "requires": { + "create-error-class": "^3.0.0", + "duplexer3": "^0.1.4", + "get-stream": "^3.0.0", + "is-redirect": "^1.0.0", + "is-retry-allowed": "^1.0.0", + "is-stream": "^1.0.0", + "lowercase-keys": "^1.0.0", + "safe-buffer": "^5.0.1", + "timed-out": "^4.0.0", + "unzip-response": "^2.0.1", + "url-parse-lax": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" + }, + "growl": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", + "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==" + }, + "handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "dev": true, + "requires": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "har-schema": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz", + "integrity": "sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4=" + }, + "har-validator": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz", + "integrity": "sha1-M0gdDxu/9gDdID11gSpqX7oALio=", + "requires": { + "ajv": "^4.9.1", + "har-schema": "^1.0.5" + } + }, + "has": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", + "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", + "requires": { + "function-bind": "^1.0.2" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=" + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", + "requires": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" + }, + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" + }, + "home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.1" + } + }, + "hooks-fixed": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hooks-fixed/-/hooks-fixed-2.0.2.tgz", + "integrity": "sha512-YurCM4gQSetcrhwEtpQHhQ4M7Zo7poNGqY4kQGeBS6eZtOcT3tnNs01ThFa0jYBByAiYt1MjMjP/YApG0EnAvQ==" + }, + "hosted-git-info": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.6.0.tgz", + "integrity": "sha512-lIbgIIQA3lz5XaB6vxakj6sDHADJiZadYEJB+FgA+C4nubM1NwcuvUr9EJPmnH1skZqpqUzWborWo8EIUi0Sdw==" + }, + "html-encoding-sniffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", + "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", + "requires": { + "whatwg-encoding": "^1.0.1" + } + }, + "htmlencode": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/htmlencode/-/htmlencode-0.0.4.tgz", + "integrity": "sha1-9+LWr74YqHp45jujMI51N2Z0Dj8=" + }, + "http": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/http/-/http-0.0.0.tgz", + "integrity": "sha1-huYybSnF0Dnen6xYSkVon5KfT3I=" + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", + "requires": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "ieee754": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", + "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" + }, + "ignore": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.7.tgz", + "integrity": "sha512-YGG3ejvBNHRqu0559EOxxNFihD0AjpvHlC/pdGKd3X3ofe+CoJkYazwNJYTNebqpPKN+VVQbh4ZFn1DivMNuHA==" + }, + "ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", + "dev": true + }, + "import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "inquirer": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", + "requires": { + "ansi-escapes": "^1.1.0", + "ansi-regex": "^2.0.0", + "chalk": "^1.0.0", + "cli-cursor": "^1.0.1", + "cli-width": "^2.0.0", + "figures": "^1.3.5", + "lodash": "^4.3.0", + "readline2": "^1.0.1", + "run-async": "^0.1.0", + "rx-lite": "^3.1.2", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.0", + "through": "^2.3.6" + } + }, + "intercom-client": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/intercom-client/-/intercom-client-2.9.1.tgz", + "integrity": "sha1-QABM7wyClYPpvE4fg/1j8FYWrAY=", + "requires": { + "bluebird": "^3.3.4", + "htmlencode": "^0.0.4", + "request": "^2.83.0" + }, + "dependencies": { + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "boom": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", + "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", + "requires": { + "hoek": "4.x.x" + } + }, + "cryptiles": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", + "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", + "requires": { + "boom": "5.x.x" + }, + "dependencies": { + "boom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", + "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", + "requires": { + "hoek": "4.x.x" + } + } + } + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + } + }, + "hawk": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", + "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", + "requires": { + "boom": "4.x.x", + "cryptiles": "3.x.x", + "hoek": "4.x.x", + "sntp": "2.x.x" + } + }, + "hoek": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz", + "integrity": "sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA==" + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "request": { + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.85.0.tgz", + "integrity": "sha512-8H7Ehijd4js+s6wuVPLjwORxD4zeuyjYugprdOXlPSqaApmL/QOy+EB/beICHVCHkGMKNh5rvihb5ov+IDw4mg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "hawk": "~6.0.2", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "stringstream": "~0.0.5", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + } + }, + "sntp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", + "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", + "requires": { + "hoek": "4.x.x" + } + } + } + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=" + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" + }, + "ip-regex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", + "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=" + }, + "ipaddr.js": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz", + "integrity": "sha1-4/o1e3c9phnybpXwSdBVxyeW+Gs=" + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "requires": { + "builtin-modules": "^1.0.0" + } + }, + "is-ci": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.1.0.tgz", + "integrity": "sha512-c7TnwxLePuqIlxHgr7xtxzycJPegNHFuIrBkwbf8hc58//+Op1CqFkyS+xnIMkwn9UsJIwc174BIjkyBmSpjKg==", + "dev": true, + "requires": { + "ci-info": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-dotfile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", + "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=" + }, + "is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", + "requires": { + "is-primitive": "^2.0.0" + } + }, + "is-expression": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-3.0.0.tgz", + "integrity": "sha1-Oayqa+f9HzRx3ELHQW5hwkMXrJ8=", + "requires": { + "acorn": "~4.0.2", + "object-assign": "^4.0.1" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=" + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=" + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "requires": { + "is-extglob": "^1.0.0" + } + }, + "is-installed-globally": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz", + "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=", + "dev": true, + "requires": { + "global-dirs": "^0.1.0", + "is-path-inside": "^1.0.0" + } + }, + "is-ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-2.0.0.tgz", + "integrity": "sha1-aO6gfooKCpTC0IDdZ0xzGrKkYas=", + "requires": { + "ip-regex": "^2.0.0" + } + }, + "is-match": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/is-match/-/is-match-0.4.1.tgz", + "integrity": "sha1-+19sZwmhVDt8fvp9lTDlt3b2H4M=", + "requires": { + "deep-equal": "^1.0.1", + "is-extendable": "^0.1.1", + "is-glob": "^2.0.1", + "micromatch": "^2.3.7" + } + }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==" + }, + "is-my-json-valid": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz", + "integrity": "sha512-IBhBslgngMQN8DDSppmgDv7RNrlFotuuDsKcrCP3+HbFaVivIBU7u9oiiErw8sH4ynx3+gOGQ3q2otkgiSi6kg==", + "requires": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^4.0.0", + "xtend": "^4.0.0" + } + }, + "is-npm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz", + "integrity": "sha1-8vtjpl5JBbQGyGBydloaTceTufQ=", + "dev": true + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true + }, + "is-odd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-2.0.0.tgz", + "integrity": "sha512-OTiixgpZAT1M4NHgS5IguFp/Vz2VI3U7Goh4/HA1adtwyLtSBrxYlcSYkhpAE07s4fKEcjrFxyvtQBND4vFQyQ==", + "dev": true, + "requires": { + "is-number": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true + } + } + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=" + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=" + }, + "is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=" + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=" + }, + "is-redirect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", + "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=", + "dev": true + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "requires": { + "has": "^1.0.1" + } + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==" + }, + "is-retry-allowed": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz", + "integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is_js": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/is_js/-/is_js-0.9.0.tgz", + "integrity": "sha1-CrlFQFArp6+iTIVqqYVWFmnpxS0=" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isemail": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-1.2.0.tgz", + "integrity": "sha1-vgPfjMPineTSxd9lASY/H6RZXpo=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + } + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "istanbul-lib-coverage": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.0.tgz", + "integrity": "sha512-GvgM/uXRwm+gLlvkWHTjDAvwynZkL9ns15calTrmhGgowlwJBbWMYzWbKqE2DT6JDP1AFXKa+Zi0EkqNCUqY0A==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.1.tgz", + "integrity": "sha512-1dYuzkOCbuR5GRJqySuZdsmsNKPL3PTuyPevQfoCXJePT9C8y1ga75neU+Tuy9+yS3G/dgx8wgOmp2KLpgdoeQ==", + "dev": true, + "requires": { + "babel-generator": "^6.18.0", + "babel-template": "^6.16.0", + "babel-traverse": "^6.18.0", + "babel-types": "^6.18.0", + "babylon": "^6.18.0", + "istanbul-lib-coverage": "^1.2.0", + "semver": "^5.3.0" + } + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, + "joi": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-6.10.1.tgz", + "integrity": "sha1-TVDDGAeRIgAP5fFq8f+OGRe3fgY=", + "requires": { + "hoek": "2.x.x", + "isemail": "1.x.x", + "moment": "2.x.x", + "topo": "1.x.x" + } + }, + "js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha1-Fzb939lyTyijaCrcYjCufk6Weds=" + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + }, + "js-yaml": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.11.0.tgz", + "integrity": "sha512-saJstZWv7oNeOyBh3+Dx1qWzhW0+e6/8eDzo7p5rDFqxntSztloLtuKu+Ejhtq82jsilwOIZYsCz+lIjthg1Hw==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "jsdom": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.8.0.tgz", + "integrity": "sha512-fZZSH6P8tVqYIQl0WKpZuQljPu2cW41Uj/c9omtyGwjwZCB8c82UAi7BSQs/F1FgWovmZsoU02z3k28eHp0Cdw==", + "requires": { + "abab": "^1.0.4", + "acorn": "^5.3.0", + "acorn-globals": "^4.1.0", + "array-equal": "^1.0.0", + "cssom": ">= 0.3.2 < 0.4.0", + "cssstyle": ">= 0.2.37 < 0.3.0", + "data-urls": "^1.0.0", + "domexception": "^1.0.0", + "escodegen": "^1.9.0", + "html-encoding-sniffer": "^1.0.2", + "left-pad": "^1.2.0", + "nwmatcher": "^1.4.3", + "parse5": "4.0.0", + "pn": "^1.1.0", + "request": "^2.83.0", + "request-promise-native": "^1.0.5", + "sax": "^1.2.4", + "symbol-tree": "^3.2.2", + "tough-cookie": "^2.3.3", + "w3c-hr-time": "^1.0.1", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.3", + "whatwg-mimetype": "^2.1.0", + "whatwg-url": "^6.4.0", + "ws": "^4.0.0", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "acorn-globals": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.1.0.tgz", + "integrity": "sha512-KjZwU26uG3u6eZcfGbTULzFcsoz6pegNKtHPksZPOUsiKo5bUmiBPa38FuHZ/Eun+XYh/JCCkS9AS3Lu4McQOQ==", + "requires": { + "acorn": "^5.0.0" + } + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "boom": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", + "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", + "requires": { + "hoek": "4.x.x" + } + }, + "cryptiles": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", + "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", + "requires": { + "boom": "5.x.x" + }, + "dependencies": { + "boom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", + "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", + "requires": { + "hoek": "4.x.x" + } + } + } + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + } + }, + "hawk": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", + "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", + "requires": { + "boom": "4.x.x", + "cryptiles": "3.x.x", + "hoek": "4.x.x", + "sntp": "2.x.x" + } + }, + "hoek": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz", + "integrity": "sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA==" + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "request": { + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.85.0.tgz", + "integrity": "sha512-8H7Ehijd4js+s6wuVPLjwORxD4zeuyjYugprdOXlPSqaApmL/QOy+EB/beICHVCHkGMKNh5rvihb5ov+IDw4mg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "hawk": "~6.0.2", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "stringstream": "~0.0.5", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "sntp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", + "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", + "requires": { + "hoek": "4.x.x" + } + }, + "ws": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-4.1.0.tgz", + "integrity": "sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA==", + "requires": { + "async-limiter": "~1.0.0", + "safe-buffer": "~5.1.0" + } + } + } + }, + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=" + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "requires": { + "jsonify": "~0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" + }, + "jsonfile": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.1.tgz", + "integrity": "sha1-pezG9l9T9mLEQVx2daAzHQmS7GY=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=" + }, + "jsonschema": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.4.tgz", + "integrity": "sha512-lz1nOH69GbsVHeVgEdvyavc/33oymY1AZwtePMiMj4HZPMbP5OIKK3zT9INMWjwua/V4Z4yq7wSlBbSG+g4AEw==" + }, + "jsonwebtoken": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.2.1.tgz", + "integrity": "sha512-l8rUBr0fqYYwPc8/ZGrue7GiW7vWdZtZqelxo4Sd5lMvuEeCK8/wS54sEo6tJhdZ6hqfutsj6COgC0d1XdbHGw==", + "requires": { + "jws": "^3.1.4", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "xtend": "^4.0.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=", + "requires": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "jwa": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.5.tgz", + "integrity": "sha1-oFUs4CIHQs1S4VN3SjKQXDDnVuU=", + "requires": { + "base64url": "2.0.0", + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.9", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.4.tgz", + "integrity": "sha1-+ei5M46KhHJ31kRLFGT2GIDgUKI=", + "requires": { + "base64url": "^2.0.0", + "jwa": "^1.1.4", + "safe-buffer": "^5.0.1" + } + }, + "kareem": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-1.5.0.tgz", + "integrity": "sha1-4+QQHZ3P3imXadr0tNtk2JXRdEg=" + }, + "kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", + "requires": { + "is-buffer": "^1.0.2" + } + }, + "klaw-sync": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-2.1.0.tgz", + "integrity": "sha1-PTvNhgDnv971MjHHOf8FOu1WDkQ=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11" + } + }, + "kue": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/kue/-/kue-0.11.6.tgz", + "integrity": "sha512-56Jic22qSqdJ3nNpkhVr6RUx/QKalfdBdU0m70hgBKEkhBAgdt6Qr74evec+bM+LGmNbEC6zGGDskX4mcgBYcQ==", + "requires": { + "body-parser": "^1.12.2", + "express": "^4.12.2", + "lodash": "^4.0.0", + "nib": "~1.1.2", + "node-redis-warlock": "~0.2.0", + "pug": "^2.0.0-beta3", + "redis": "~2.6.0-2", + "reds": "^0.2.5", + "stylus": "~0.54.5", + "yargs": "^4.0.0" + } + }, + "kuler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", + "integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==", + "dev": true, + "requires": { + "colornames": "^1.1.1" + } + }, + "latest-version": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", + "integrity": "sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=", + "dev": true, + "requires": { + "package-json": "^4.0.0" + } + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=" + }, + "lazystream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", + "integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=", + "requires": { + "readable-stream": "^2.0.5" + } + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "requires": { + "invert-kv": "^1.0.0" + } + }, + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "linkify-it": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.1.0.tgz", + "integrity": "sha512-4REs8/062kV2DSHxNfq5183zrqXMl7WP0WzABH9IeJI+NLm429FgE1PDecltYfnOoFDFlZGh2T8PfZn0r+GTRg==", + "dev": true, + "requires": { + "uc.micro": "^1.0.1" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "requires": { + "is-utf8": "^0.2.0" + } + } + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + } + } + }, + "lodash": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.5.tgz", + "integrity": "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==" + }, + "lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=" + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" + }, + "logform": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-1.10.0.tgz", + "integrity": "sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==", + "dev": true, + "requires": { + "colors": "^1.2.1", + "fast-safe-stringify": "^2.0.4", + "fecha": "^2.3.3", + "ms": "^2.1.1", + "triple-beam": "^1.2.0" + }, + "dependencies": { + "colors": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.3.tgz", + "integrity": "sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "login-with-amazon": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/login-with-amazon/-/login-with-amazon-0.0.3.tgz", + "integrity": "sha1-raYDpq5s9vB/ReQMdmekR5KjVcI=", + "requires": { + "bhttp": "^1.2.4" + } + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" + }, + "loose-envify": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", + "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", + "requires": { + "js-tokens": "^3.0.0" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true + }, + "lru-cache": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=" + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "markdown": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/markdown/-/markdown-0.5.0.tgz", + "integrity": "sha1-KCBbVlqK51kt4gdGPWY33BgnIrI=", + "requires": { + "nopt": "~2.1.1" + } + }, + "markdown-it": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz", + "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "entities": "~1.1.1", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "md-recast": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/md-recast/-/md-recast-0.0.2.tgz", + "integrity": "sha512-5GeJBJA1gXZ7Hd2L6PKK++dijiBs75UTPcnzToIjCVFMA84B2J4rYzP4PTcovFsHfFChPopREW21TdgGPqL1aQ==", + "requires": { + "markdown": "0.5.0" + } + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", + "requires": { + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==" + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "requires": { + "mime-db": "~1.33.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mixin-deep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", + "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.1.1.tgz", + "integrity": "sha512-kKKs/H1KrMMQIEsWNxGmb4/BGsmj0dkeyotEvbrAuQ01FcWRLssUNXCEUZk6SZtyJBi6EE7SL0zDDtItw1rGhw==", + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.11.0", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.3", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "4.4.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "supports-color": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", + "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "requires": { + "has-flag": "^2.0.0" + } + } + } + }, + "moment": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz", + "integrity": "sha512-shJkRTSebXvsVqk56I+lkb2latjBs8I+pc2TzWc545y2iFnSjm7Wg0QMh+ZWcdSLQyGEau5jI8ocnmkyTgr9YQ==" + }, + "mongodb": { + "version": "2.2.34", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.34.tgz", + "integrity": "sha1-o09Zu+thdUrsQy3nLD/iFSakTBo=", + "requires": { + "es6-promise": "3.2.1", + "mongodb-core": "2.1.18", + "readable-stream": "2.2.7" + }, + "dependencies": { + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "readable-stream": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz", + "integrity": "sha1-BwV6y+JGeyIELTb5jFrVBwVOlbE=", + "requires": { + "buffer-shims": "~1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~1.0.0", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "mongodb-core": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.18.tgz", + "integrity": "sha1-TEYTm986HwMt7ZHbSfOO7AFlkFA=", + "requires": { + "bson": "~1.0.4", + "require_optional": "~1.0.0" + } + }, + "mongoose": { + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-4.13.12.tgz", + "integrity": "sha512-pH8NK5AYGbnPeEFFGs5ACk18vzzcy4DFT48U9kKvkfg6SI3nJZkzGfN7o1NDWjy+kP26hWyU/AMhYTfe5hSVnA==", + "requires": { + "async": "2.1.4", + "bson": "~1.0.4", + "hooks-fixed": "2.0.2", + "kareem": "1.5.0", + "lodash.get": "4.4.2", + "mongodb": "2.2.34", + "mpath": "0.3.0", + "mpromise": "0.5.5", + "mquery": "2.3.3", + "ms": "2.0.0", + "muri": "1.3.0", + "regexp-clone": "0.0.1", + "sliced": "1.0.1" + }, + "dependencies": { + "async": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.1.4.tgz", + "integrity": "sha1-LSFgx3iAMuTdbL4lAvH5osj2zeQ=", + "requires": { + "lodash": "^4.14.0" + } + } + } + }, + "morgan": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.0.tgz", + "integrity": "sha1-0B+mxlhZt2/PMbPLU6OCGjEdgFE=", + "requires": { + "basic-auth": "~2.0.0", + "debug": "2.6.9", + "depd": "~1.1.1", + "on-finished": "~2.3.0", + "on-headers": "~1.0.1" + } + }, + "mpath": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.3.0.tgz", + "integrity": "sha1-elj3iem1/TyUUgY0FXlg8mvV70Q=" + }, + "mpromise": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mpromise/-/mpromise-0.5.5.tgz", + "integrity": "sha1-9bJCWddjrMIlewoMjG2Gb9UXMuY=" + }, + "mquery": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-2.3.3.tgz", + "integrity": "sha512-NC8L14kn+qxJbbJ1gbcEMDxF0sC3sv+1cbRReXXwVvowcwY1y9KoVZFq0ebwARibsadu8lx8nWGvm3V0Pf0ZWQ==", + "requires": { + "bluebird": "3.5.0", + "debug": "2.6.9", + "regexp-clone": "0.0.1", + "sliced": "0.0.5" + }, + "dependencies": { + "bluebird": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz", + "integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw=" + }, + "sliced": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-0.0.5.tgz", + "integrity": "sha1-XtwETKTrb3gW1Qui/GPiXY/kcH8=" + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "muri": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/muri/-/muri-1.3.0.tgz", + "integrity": "sha512-FiaFwKl864onHFFUV/a2szAl7X0fxVlSKNdhTf+BM8i8goEgYut8u5P9MqQqIYwvaMxjzVESsoEm/2kfkFH1rg==" + }, + "mute-stream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", + "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=" + }, + "nan": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "dev": true, + "optional": true + }, + "nanomatch": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.9.tgz", + "integrity": "sha512-n8R9bS8yQ6eSXaV6jHUpKzD8gLsin02w1HSFiegwrs9E098Ylhw5jdyKPaYqvHknHaSCKTPp7C8dGCQ0q9koXA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-odd": "^2.0.0", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + } + }, + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "natural": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/natural/-/natural-0.2.1.tgz", + "integrity": "sha1-HrUVap2QtFkZSeIOlOvHe7Iznto=", + "optional": true, + "requires": { + "apparatus": ">= 0.0.9", + "sylvester": ">= 0.0.12", + "underscore": ">=1.3.1" + } + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" + }, + "nib": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/nib/-/nib-1.1.2.tgz", + "integrity": "sha1-amnt5AgblcDe+L4CSkyK4MLLtsc=", + "requires": { + "stylus": "0.54.5" + } + }, + "nock": { + "version": "9.2.5", + "resolved": "https://registry.npmjs.org/nock/-/nock-9.2.5.tgz", + "integrity": "sha512-ciCpyEq72Ws6/yhdayDfd0mAb3eQ7/533xKmFlBQZ5CDwrL0/bddtSicfL7R07oyvPAuegQrR+9ctrlPEp0EjQ==", + "dev": true, + "requires": { + "chai": "^4.1.2", + "debug": "^3.1.0", + "deep-equal": "^1.0.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.5", + "mkdirp": "^0.5.0", + "propagate": "^1.0.0", + "qs": "^6.5.1", + "semver": "^5.5.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "node-forge": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.5.tgz", + "integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==" + }, + "node-redis-scripty": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/node-redis-scripty/-/node-redis-scripty-0.0.5.tgz", + "integrity": "sha1-S/LTZattqyAswIt6xj+PVarcliU=", + "requires": { + "extend": "^1.2.1", + "lru-cache": "^2.5.0" + }, + "dependencies": { + "extend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extend/-/extend-1.3.0.tgz", + "integrity": "sha1-0VFvsP9WJNLr+RI+odrFoZlABPg=" + } + } + }, + "node-redis-warlock": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-redis-warlock/-/node-redis-warlock-0.2.0.tgz", + "integrity": "sha1-VjlbmUyCjo4y9qrlO5O27fzZeZA=", + "requires": { + "node-redis-scripty": "0.0.5", + "uuid": "^2.0.1" + }, + "dependencies": { + "uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" + } + } + }, + "node-sparky": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/node-sparky/-/node-sparky-4.4.0.tgz", + "integrity": "sha512-gJZ4TtGMjLl6jziisaZNRkQc3roPeFnN6GIg2BbYC/20qE/h3gH/VokrG9lwzzlQUeRt6lwMo+/rxpIcEifESg==", + "requires": { + "lodash": "4.13.1", + "mime-types": "2.1.x", + "moment": "2.20.x", + "request": "2.79.x", + "validator": "9.1.x", + "when": "3.7.x" + }, + "dependencies": { + "caseless": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", + "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=" + }, + "har-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", + "requires": { + "chalk": "^1.1.1", + "commander": "^2.9.0", + "is-my-json-valid": "^2.12.4", + "pinkie-promise": "^2.0.0" + } + }, + "lodash": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.13.1.tgz", + "integrity": "sha1-g+SxCRP0hJbU0W/sSlYK8u50S2g=" + }, + "moment": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", + "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==" + }, + "qs": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.2.tgz", + "integrity": "sha1-51vV9uJoEioqDgvaYwslUMFmUCw=" + }, + "request": { + "version": "2.79.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", + "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", + "requires": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.11.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~2.0.6", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "qs": "~6.3.0", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "~0.4.1", + "uuid": "^3.0.0" + } + }, + "tunnel-agent": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", + "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=" + }, + "validator": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/validator/-/validator-9.1.2.tgz", + "integrity": "sha512-1Tml6crNdsSC61jHssWksQxq6C7MmSFCCmf99Eb+l/V/cwVlw4/Pg3YXBP1WKcHLsyqe3E+iJXUZgoTTQFcqQg==" + } + } + }, + "nodemailer": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-4.6.4.tgz", + "integrity": "sha512-SD4uuX7NMzZ5f5m1XHDd13J4UC3SmdJk8DsmU1g6Nrs5h3x9LcXr6EBPZIqXRJ3LrF7RdklzGhZRF/TuylTcLg==" + }, + "nodemon": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.17.5.tgz", + "integrity": "sha512-FG2mWJU1Y58a9ktgMJ/RZpsiPz3b7ge77t/okZHEa4NbrlXGKZ8s1A6Q+C7+JPXohAfcPALRwvxcAn8S874pmw==", + "dev": true, + "requires": { + "chokidar": "^2.0.2", + "debug": "^3.1.0", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.0.4", + "pstree.remy": "^1.1.0", + "semver": "^5.5.0", + "supports-color": "^5.2.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.2", + "update-notifier": "^2.3.0" + }, + "dependencies": { + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fsevents": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.21", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": "^2.1.0" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "^5.1.1", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.0", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.1.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.5.1", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-buffer": { + "version": "5.1.1", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.5.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.0.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.2.4", + "minizlib": "^1.1.0", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.1", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.0.2", + "bundled": true, + "dev": true + } + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "nopt": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-2.1.2.tgz", + "integrity": "sha1-bMzZd7gBMqB3MdbozljCyDA8+a8=", + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "requires": { + "hosted-git-info": "^2.1.4", + "is-builtin-module": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "nwmatcher": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz", + "integrity": "sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==" + }, + "nyc": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-11.7.1.tgz", + "integrity": "sha512-EGePURSKUEpS1jWnEKAMhY+GWZzi7JC+f8iBDOATaOsLZW5hM/9eYx2dHGaEXa1ITvMm44CJugMksvP3NwMQMw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "arrify": "^1.0.1", + "caching-transform": "^1.0.0", + "convert-source-map": "^1.5.1", + "debug-log": "^1.0.1", + "default-require-extensions": "^1.0.0", + "find-cache-dir": "^0.1.1", + "find-up": "^2.1.0", + "foreground-child": "^1.5.3", + "glob": "^7.0.6", + "istanbul-lib-coverage": "^1.1.2", + "istanbul-lib-hook": "^1.1.0", + "istanbul-lib-instrument": "^1.10.0", + "istanbul-lib-report": "^1.1.3", + "istanbul-lib-source-maps": "^1.2.3", + "istanbul-reports": "^1.4.0", + "md5-hex": "^1.2.0", + "merge-source-map": "^1.0.2", + "micromatch": "^2.3.11", + "mkdirp": "^0.5.0", + "resolve-from": "^2.0.0", + "rimraf": "^2.5.4", + "signal-exit": "^3.0.1", + "spawn-wrap": "^1.4.2", + "test-exclude": "^4.2.0", + "yargs": "11.1.0", + "yargs-parser": "^8.0.0" + }, + "dependencies": { + "align-text": { + "version": "0.1.4", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + } + }, + "amdefine": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "bundled": true, + "dev": true + }, + "append-transform": { + "version": "0.4.0", + "bundled": true, + "dev": true, + "requires": { + "default-require-extensions": "^1.0.0" + } + }, + "archy": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "arr-diff": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "arr-flatten": "^1.0.1" + } + }, + "arr-flatten": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "bundled": true, + "dev": true + }, + "array-unique": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "arrify": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "async": { + "version": "1.5.2", + "bundled": true, + "dev": true + }, + "atob": { + "version": "2.1.0", + "bundled": true, + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + } + }, + "babel-generator": { + "version": "6.26.1", + "bundled": true, + "dev": true, + "requires": { + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.17.4", + "source-map": "^0.5.7", + "trim-right": "^1.0.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "bundled": true, + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-runtime": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "babel-template": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "lodash": "^4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "bundled": true, + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "base": { + "version": "0.11.2", + "bundled": true, + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "bundled": true, + "dev": true + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "1.8.5", + "bundled": true, + "dev": true, + "requires": { + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" + } + }, + "builtin-modules": { + "version": "1.1.1", + "bundled": true, + "dev": true + }, + "cache-base": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "caching-transform": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "md5-hex": "^1.2.0", + "mkdirp": "^0.5.1", + "write-file-atomic": "^1.1.4" + } + }, + "camelcase": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true + }, + "center-align": { + "version": "0.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chalk": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "class-utils": { + "version": "0.3.6", + "bundled": true, + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "cliui": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "commondir": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "component-emitter": { + "version": "1.2.1", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "convert-source-map": { + "version": "1.5.1", + "bundled": true, + "dev": true + }, + "copy-descriptor": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "core-js": { + "version": "2.5.5", + "bundled": true, + "dev": true + }, + "cross-spawn": { + "version": "4.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "debug-log": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "decamelize": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "default-require-extensions": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "strip-bom": "^2.0.0" + } + }, + "define-property": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "bundled": true, + "dev": true + } + } + }, + "detect-indent": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "error-ex": { + "version": "1.3.1", + "bundled": true, + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "bundled": true, + "dev": true + }, + "esutils": { + "version": "2.0.2", + "bundled": true, + "dev": true + }, + "execa": { + "version": "0.7.0", + "bundled": true, + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "5.1.0", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + } + } + }, + "expand-brackets": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "requires": { + "is-posix-bracket": "^0.1.0" + } + }, + "expand-range": { + "version": "1.8.2", + "bundled": true, + "dev": true, + "requires": { + "fill-range": "^2.1.0" + } + }, + "extend-shallow": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "0.3.2", + "bundled": true, + "dev": true, + "requires": { + "is-extglob": "^1.0.0" + } + }, + "filename-regex": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "fill-range": { + "version": "2.2.3", + "bundled": true, + "dev": true, + "requires": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^1.1.3", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + } + }, + "find-cache-dir": { + "version": "0.1.1", + "bundled": true, + "dev": true, + "requires": { + "commondir": "^1.0.1", + "mkdirp": "^0.5.1", + "pkg-dir": "^1.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "for-in": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "for-own": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + }, + "foreground-child": { + "version": "1.5.6", + "bundled": true, + "dev": true, + "requires": { + "cross-spawn": "^4", + "signal-exit": "^3.0.0" + } + }, + "fragment-cache": { + "version": "0.2.1", + "bundled": true, + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "get-caller-file": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "get-value": { + "version": "2.0.6", + "bundled": true, + "dev": true + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-base": { + "version": "0.3.0", + "bundled": true, + "dev": true, + "requires": { + "glob-parent": "^2.0.0", + "is-glob": "^2.0.0" + } + }, + "glob-parent": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-glob": "^2.0.0" + } + }, + "globals": { + "version": "9.18.0", + "bundled": true, + "dev": true + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true, + "dev": true + }, + "handlebars": { + "version": "4.0.11", + "bundled": true, + "dev": true, + "requires": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "bundled": true, + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "has-ansi": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "has-value": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "has-values": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hosted-git-info": { + "version": "2.6.0", + "bundled": true, + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "bundled": true, + "dev": true + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "invariant": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "invert-kv": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "is-arrayish": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "bundled": true, + "dev": true + }, + "is-builtin-module": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "builtin-modules": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "is-descriptor": { + "version": "0.1.6", + "bundled": true, + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "bundled": true, + "dev": true + } + } + }, + "is-dotfile": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "is-equal-shallow": { + "version": "0.1.3", + "bundled": true, + "dev": true, + "requires": { + "is-primitive": "^2.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "is-extglob": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "is-glob": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extglob": "^1.0.0" + } + }, + "is-number": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "is-odd": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-number": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "bundled": true, + "dev": true + } + } + }, + "is-plain-object": { + "version": "2.0.4", + "bundled": true, + "dev": true, + "requires": { + "isobject": "^3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "is-primitive": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "is-utf8": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "isexe": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "isobject": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "isarray": "1.0.0" + } + }, + "istanbul-lib-coverage": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "istanbul-lib-hook": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "append-transform": "^0.4.0" + } + }, + "istanbul-lib-instrument": { + "version": "1.10.1", + "bundled": true, + "dev": true, + "requires": { + "babel-generator": "^6.18.0", + "babel-template": "^6.16.0", + "babel-traverse": "^6.18.0", + "babel-types": "^6.18.0", + "babylon": "^6.18.0", + "istanbul-lib-coverage": "^1.2.0", + "semver": "^5.3.0" + } + }, + "istanbul-lib-report": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "requires": { + "istanbul-lib-coverage": "^1.1.2", + "mkdirp": "^0.5.1", + "path-parse": "^1.0.5", + "supports-color": "^3.1.2" + }, + "dependencies": { + "supports-color": { + "version": "3.2.3", + "bundled": true, + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "1.2.3", + "bundled": true, + "dev": true, + "requires": { + "debug": "^3.1.0", + "istanbul-lib-coverage": "^1.1.2", + "mkdirp": "^0.5.1", + "rimraf": "^2.6.1", + "source-map": "^0.5.3" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "istanbul-reports": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "handlebars": "^4.0.3" + } + }, + "js-tokens": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "jsesc": { + "version": "1.3.0", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + }, + "lazy-cache": { + "version": "1.0.4", + "bundled": true, + "dev": true, + "optional": true + }, + "lcid": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "load-json-file": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "bundled": true, + "dev": true + } + } + }, + "lodash": { + "version": "4.17.5", + "bundled": true, + "dev": true + }, + "longest": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "loose-envify": { + "version": "1.3.1", + "bundled": true, + "dev": true, + "requires": { + "js-tokens": "^3.0.0" + } + }, + "lru-cache": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "map-cache": { + "version": "0.2.2", + "bundled": true, + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "md5-hex": { + "version": "1.3.0", + "bundled": true, + "dev": true, + "requires": { + "md5-o-matic": "^0.1.1" + } + }, + "md5-o-matic": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "mem": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "merge-source-map": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "bundled": true, + "dev": true + } + } + }, + "micromatch": { + "version": "2.3.11", + "bundled": true, + "dev": true, + "requires": { + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" + } + }, + "mimic-fn": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "mixin-deep": { + "version": "1.3.1", + "bundled": true, + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "nanomatch": { + "version": "1.2.9", + "bundled": true, + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-odd": "^2.0.0", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "bundled": true, + "dev": true + } + } + }, + "normalize-package-data": { + "version": "2.4.0", + "bundled": true, + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "is-builtin-module": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "npm-run-path": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "bundled": true, + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "isobject": "^3.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "object.omit": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "for-own": "^0.1.4", + "is-extendable": "^0.1.1" + } + }, + "object.pick": { + "version": "1.3.0", + "bundled": true, + "dev": true, + "requires": { + "isobject": "^3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optimist": { + "version": "0.6.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "os-locale": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "p-finally": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "p-limit": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "parse-glob": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "glob-base": "^0.3.0", + "is-dotfile": "^1.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.0" + } + }, + "parse-json": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "pascalcase": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "path-exists": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "path-key": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "bundled": true, + "dev": true + }, + "path-type": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "bundled": true, + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "bundled": true, + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "find-up": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "preserve": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "randomatic": { + "version": "1.1.7", + "bundled": true, + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "read-pkg": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + } + } + }, + "regenerator-runtime": { + "version": "0.11.1", + "bundled": true, + "dev": true + }, + "regex-cache": { + "version": "0.4.4", + "bundled": true, + "dev": true, + "requires": { + "is-equal-shallow": "^0.1.3" + } + }, + "regex-not": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "repeat-element": { + "version": "1.1.2", + "bundled": true, + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "bundled": true, + "dev": true + }, + "repeating": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "resolve-from": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "ret": { + "version": "0.1.15", + "bundled": true, + "dev": true + }, + "right-align": { + "version": "0.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.1" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-regex": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "semver": { + "version": "5.5.0", + "bundled": true, + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "set-value": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "shebang-command": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "slide": { + "version": "1.1.6", + "bundled": true, + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "bundled": true, + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "bundled": true, + "dev": true + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.2.0" + } + }, + "source-map": { + "version": "0.5.7", + "bundled": true, + "dev": true + }, + "source-map-resolve": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "atob": "^2.0.0", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-url": { + "version": "0.4.0", + "bundled": true, + "dev": true + }, + "spawn-wrap": { + "version": "1.4.2", + "bundled": true, + "dev": true, + "requires": { + "foreground-child": "^1.5.6", + "mkdirp": "^0.5.0", + "os-homedir": "^1.0.1", + "rimraf": "^2.6.2", + "signal-exit": "^3.0.2", + "which": "^1.3.0" + } + }, + "spdx-correct": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "bundled": true, + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "split-string": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "static-extend": { + "version": "0.1.2", + "bundled": true, + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "string-width": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-eof": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "supports-color": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "test-exclude": { + "version": "4.2.1", + "bundled": true, + "dev": true, + "requires": { + "arrify": "^1.0.1", + "micromatch": "^3.1.8", + "object-assign": "^4.1.0", + "read-pkg-up": "^1.0.1", + "require-main-filename": "^1.0.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "bundled": true, + "dev": true + }, + "braces": { + "version": "2.3.2", + "bundled": true, + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "bundled": true, + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "bundled": true, + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + } + }, + "kind-of": { + "version": "5.1.0", + "bundled": true, + "dev": true + } + } + }, + "extglob": { + "version": "2.0.4", + "bundled": true, + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "is-number": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "bundled": true, + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "bundled": true, + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + } + } + }, + "to-fast-properties": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "to-regex": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + } + } + }, + "trim-right": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "uglify-js": { + "version": "2.8.29", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "yargs": { + "version": "3.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "union-value": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^0.4.3" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "set-value": { + "version": "0.4.3", + "bundled": true, + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.1", + "to-object-path": "^0.3.0" + } + } + } + }, + "unset-value": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "bundled": true, + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "bundled": true, + "dev": true + }, + "isobject": { + "version": "3.0.1", + "bundled": true, + "dev": true + } + } + }, + "urix": { + "version": "0.1.0", + "bundled": true, + "dev": true + }, + "use": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "^6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "bundled": true, + "dev": true + } + } + }, + "validate-npm-package-license": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "which": { + "version": "1.3.0", + "bundled": true, + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "window-size": { + "version": "0.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "wordwrap": { + "version": "0.0.3", + "bundled": true, + "dev": true + }, + "wrap-ansi": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "write-file-atomic": { + "version": "1.3.4", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "slide": "^1.1.5" + } + }, + "y18n": { + "version": "3.2.1", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "2.1.2", + "bundled": true, + "dev": true + }, + "yargs": { + "version": "11.1.0", + "bundled": true, + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.1.1", + "find-up": "^2.1.0", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^9.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "bundled": true, + "dev": true + }, + "cliui": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "yargs-parser": { + "version": "9.0.2", + "bundled": true, + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "yargs-parser": { + "version": "8.1.0", + "bundled": true, + "dev": true, + "requires": { + "camelcase": "^4.1.0" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "bundled": true, + "dev": true + } + } + } + } + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "object.omit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=", + "requires": { + "for-own": "^0.1.4", + "is-extendable": "^0.1.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "requires": { + "isobject": "^3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "one-time": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", + "integrity": "sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=", + "dev": true + }, + "onetime": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "options": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, + "os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "requires": { + "lcid": "^1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "output-file-sync": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/output-file-sync/-/output-file-sync-1.1.2.tgz", + "integrity": "sha1-0KM+7+YaIF+suQCS6CZZjVJFznY=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.4", + "mkdirp": "^0.5.1", + "object-assign": "^4.1.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.2.0.tgz", + "integrity": "sha512-Y/OtIaXtUPr4/YpMv1pCL5L5ed0rumAaAeBSj12F+bSlMdys7i8oQF/GUJmfpTS/QoaRrS/k6pma29haJpsMng==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "package-json": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-4.0.1.tgz", + "integrity": "sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=", + "dev": true, + "requires": { + "got": "^6.7.1", + "registry-auth-token": "^3.0.1", + "registry-url": "^3.0.3", + "semver": "^5.1.0" + } + }, + "parse-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", + "requires": { + "glob-base": "^0.3.0", + "is-dotfile": "^1.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.0" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "requires": { + "pinkie-promise": "^2.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=" + }, + "pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", + "dev": true, + "requires": { + "through": "~2.3" + } + }, + "performance-now": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz", + "integrity": "sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU=" + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "requires": { + "pinkie": "^2.0.0" + } + }, + "pluralize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", + "integrity": "sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU=" + }, + "pn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", + "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==" + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" + }, + "prepend-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", + "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", + "dev": true + }, + "preserve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=" + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=" + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, + "propagate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz", + "integrity": "sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk=", + "dev": true + }, + "proxy-addr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.3.tgz", + "integrity": "sha512-jQTChiCJteusULxjBp8+jftSQE5Obdl3k4cnmLA6WXtK6XFuWRnvVL7aCiBqaLPM8c4ph0S4tKna8XvmIwEnXQ==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.6.0" + } + }, + "ps-tree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.1.0.tgz", + "integrity": "sha1-tCGyQUDWID8e08dplrRCewjowBQ=", + "dev": true, + "requires": { + "event-stream": "~3.3.0" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "pstree.remy": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.0.tgz", + "integrity": "sha512-q5I5vLRMVtdWa8n/3UEzZX7Lfghzrg9eG2IKk2ENLSofKRCXVqMvMUHxCKgXNaqH/8ebhBxrqftHWnyTFweJ5Q==", + "dev": true, + "requires": { + "ps-tree": "^1.1.0" + } + }, + "pug": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pug/-/pug-2.0.3.tgz", + "integrity": "sha1-ccuoJTfJWl6rftBGluQiH1Oqh44=", + "requires": { + "pug-code-gen": "^2.0.1", + "pug-filters": "^3.1.0", + "pug-lexer": "^4.0.0", + "pug-linker": "^3.0.5", + "pug-load": "^2.0.11", + "pug-parser": "^5.0.0", + "pug-runtime": "^2.0.4", + "pug-strip-comments": "^1.0.3" + } + }, + "pug-attrs": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-2.0.3.tgz", + "integrity": "sha1-owlflw5kFR972tlX7vVftdeQXRU=", + "requires": { + "constantinople": "^3.0.1", + "js-stringify": "^1.0.1", + "pug-runtime": "^2.0.4" + } + }, + "pug-code-gen": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-2.0.1.tgz", + "integrity": "sha1-CVHsgyJddNjPxHan+Zolm199BQw=", + "requires": { + "constantinople": "^3.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.1", + "pug-attrs": "^2.0.3", + "pug-error": "^1.3.2", + "pug-runtime": "^2.0.4", + "void-elements": "^2.0.1", + "with": "^5.0.0" + } + }, + "pug-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-1.3.2.tgz", + "integrity": "sha1-U659nSm7A89WRJOgJhCfVMR/XyY=" + }, + "pug-filters": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-3.1.0.tgz", + "integrity": "sha1-JxZVVbwEwjbkqisDZiRt+gIbYm4=", + "requires": { + "clean-css": "^4.1.11", + "constantinople": "^3.0.1", + "jstransformer": "1.0.0", + "pug-error": "^1.3.2", + "pug-walk": "^1.1.7", + "resolve": "^1.1.6", + "uglify-js": "^2.6.1" + } + }, + "pug-lexer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-4.0.0.tgz", + "integrity": "sha1-IQwYRX7y4XYCQnQMXmR715TOwng=", + "requires": { + "character-parser": "^2.1.1", + "is-expression": "^3.0.0", + "pug-error": "^1.3.2" + } + }, + "pug-linker": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-3.0.5.tgz", + "integrity": "sha1-npp65ABWgtAn3uuWsAD4juuDoC8=", + "requires": { + "pug-error": "^1.3.2", + "pug-walk": "^1.1.7" + } + }, + "pug-load": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-2.0.11.tgz", + "integrity": "sha1-5kjlftET/iwfRdV4WOorrWvAFSc=", + "requires": { + "object-assign": "^4.1.0", + "pug-walk": "^1.1.7" + } + }, + "pug-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-5.0.0.tgz", + "integrity": "sha1-45Stmz/KkxI5QK/4hcBuRKt+aOQ=", + "requires": { + "pug-error": "^1.3.2", + "token-stream": "0.0.1" + } + }, + "pug-runtime": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-2.0.4.tgz", + "integrity": "sha1-4XjhvaaKsujArPybztLFT9iM61g=" + }, + "pug-strip-comments": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-1.0.3.tgz", + "integrity": "sha1-8VWVkiBu3G+FMQ2s9K+0igJa9Z8=", + "requires": { + "pug-error": "^1.3.2" + } + }, + "pug-walk": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-1.1.7.tgz", + "integrity": "sha1-wA1cUSi6xYBr7BXSt+fNq+QlMfM=" + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "random-words": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/random-words/-/random-words-0.0.1.tgz", + "integrity": "sha1-QOMAkgM62Ptg1mrRW+NiDTwlxB8=" + }, + "randomatic": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz", + "integrity": "sha512-D5JUjPyJbaJDkuAazpVnSfVkLlpeO3wDlPROTMLGKG1zMFNFRgrciKo1ltz/AzNTkqE0HzDx655QOL51N06how==", + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + }, + "dependencies": { + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": ">= 1.3.1 < 2" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + } + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + } + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz", + "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "minimatch": "^3.0.2", + "readable-stream": "^2.0.2", + "set-immediate-shim": "^1.0.1" + } + }, + "readline2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", + "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "mute-stream": "0.0.5" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "requires": { + "resolve": "^1.1.6" + } + }, + "recursive-readdir": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", + "integrity": "sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==", + "requires": { + "minimatch": "3.0.4" + } + }, + "redis": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/redis/-/redis-2.6.5.tgz", + "integrity": "sha1-h8Hv9KSJ+Utwhx89CLaYjyOpVoc=", + "requires": { + "double-ended-queue": "^2.1.0-0", + "redis-commands": "^1.2.0", + "redis-parser": "^2.0.0" + } + }, + "redis-commands": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.5.tgz", + "integrity": "sha512-foGF8u6MXGFF++1TZVC6icGXuMYPftKXt1FBT2vrfU9ZATNtZJ8duRC5d1lEfE8hyVe3jhelHGB91oB7I6qLsA==" + }, + "redis-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", + "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=" + }, + "reds": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/reds/-/reds-0.2.5.tgz", + "integrity": "sha1-OKdn92Y810kDaEhpfYLHT9KbwB8=", + "optional": true, + "requires": { + "natural": "^0.2.0", + "redis": "^0.12.1" + }, + "dependencies": { + "redis": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz", + "integrity": "sha1-ZN92rQ/IrOuuvSoGReikj6xJGF4=", + "optional": true + } + } + }, + "regenerate": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz", + "integrity": "sha512-jVpo1GadrDAK59t/0jRx5VxYWQEDkkEKi6+HjE3joFVLfDOh9Xrdh0dF1eSq+BI/SwvTQ44gSscJ8N5zYL61sg==" + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, + "regenerator-transform": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz", + "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==", + "requires": { + "babel-runtime": "^6.18.0", + "babel-types": "^6.19.0", + "private": "^0.1.6" + }, + "dependencies": { + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + } + } + }, + "regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", + "requires": { + "is-equal-shallow": "^0.1.3" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + } + }, + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "regexp-clone": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz", + "integrity": "sha1-p8LgmJH9vzj7sQ03b7cwA+aKxYk=" + }, + "regexpu-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", + "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=", + "requires": { + "regenerate": "^1.2.1", + "regjsgen": "^0.2.0", + "regjsparser": "^0.1.4" + } + }, + "registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "requires": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha1-PU74cPc93h138M+aOBQyRE4XSUI=", + "dev": true, + "requires": { + "rc": "^1.0.1" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=" + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" + } + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=" + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "requires": { + "is-finite": "^1.0.0" + } + }, + "request": { + "version": "2.81.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.81.0.tgz", + "integrity": "sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA=", + "requires": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~4.2.1", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "performance-now": "^0.2.0", + "qs": "~6.4.0", + "safe-buffer": "^5.0.1", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.0.0" + }, + "dependencies": { + "qs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", + "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=" + } + } + }, + "request-promise-core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.1.tgz", + "integrity": "sha1-Pu4AssWqgyOc+wTFcA2jb4HNCLY=", + "requires": { + "lodash": "^4.13.1" + } + }, + "request-promise-native": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.5.tgz", + "integrity": "sha1-UoF3D2jgyXGeUWP9P6tIIhX0/aU=", + "requires": { + "request-promise-core": "1.1.1", + "stealthy-require": "^1.1.0", + "tough-cookie": ">=2.3.3" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=" + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "requires": { + "caller-path": "^0.1.0", + "resolve-from": "^1.0.0" + } + }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "^2.0.0", + "semver": "^5.1.0" + }, + "dependencies": { + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + } + } + }, + "resolve": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=" + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "requires": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "requires": { + "align-text": "^0.1.1" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "requires": { + "glob": "^7.0.5" + } + }, + "rsa-pem-from-mod-exp": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.4.tgz", + "integrity": "sha1-NipCxtMEBW1JOz8SvOq7LGV2ptQ=" + }, + "run-async": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", + "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", + "requires": { + "once": "^1.3.0" + } + }, + "rx-lite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", + "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=" + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "sax": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz", + "integrity": "sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE=" + }, + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==" + }, + "semver-diff": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz", + "integrity": "sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=", + "dev": true, + "requires": { + "semver": "^5.0.3" + } + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shelljs": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.7.8.tgz", + "integrity": "sha1-3svPh0sNHl+3LhSxZKloMEjprLM=", + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "should": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/should/-/should-11.2.1.tgz", + "integrity": "sha1-kPVRRVUtAc/CAGZuToGKHJZw7aI=", + "dev": true, + "requires": { + "should-equal": "^1.0.0", + "should-format": "^3.0.2", + "should-type": "^1.4.0", + "should-type-adaptors": "^1.0.1", + "should-util": "^1.0.0" + } + }, + "should-equal": { + "version": "1.0.1", + "resolved": "http://registry.npmjs.org/should-equal/-/should-equal-1.0.1.tgz", + "integrity": "sha1-C26VFvJgGp+wuy3MNpr6HH4gCvc=", + "dev": true, + "requires": { + "should-type": "^1.0.0" + } + }, + "should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha1-m/yPdPo5IFxT04w01xcwPidxJPE=", + "dev": true, + "requires": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=", + "dev": true + }, + "should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "requires": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "should-util": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.0.tgz", + "integrity": "sha1-yYzaN0qmsZDfi6h8mInCtNtiAGM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=" + }, + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=" + }, + "sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=" + }, + "slug": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/slug/-/slug-0.9.1.tgz", + "integrity": "sha1-rwj2CKfBFRa2F3iqgA3OhMUYz9o=", + "requires": { + "unicode": ">= 0.3.1" + } + }, + "slugify": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.2.9.tgz", + "integrity": "sha512-n0cdJ+kN3slJu8SbZXt/EHjljBqF6MxvMGSg/NPpBzoY7yyXoH38wp/ox20a1JaG1KgmdTN5Lf3aS9+xB2Y2aQ==" + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", + "requires": { + "hoek": "2.x.x" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "source-map-resolve": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.1.tgz", + "integrity": "sha512-0KW2wvzfxm8NCTb30z0LMNyPqWCdDGE2viwzUaucqJdkTRXtZiSY3I+2A6nVAjmdOy0I4gU8DwnVVGsk9jvP2A==", + "dev": true, + "requires": { + "atob": "^2.0.0", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "requires": { + "source-map": "^0.5.6" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "spdx-correct": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", + "integrity": "sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g==", + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz", + "integrity": "sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg==" + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.0.tgz", + "integrity": "sha512-2+EPwgbnmOIl8HjGBXXMd9NAu02vLjOO1nWw4kmeRDFyHn+M/ETfHxQUK0oXg8ctgVnl9t3rosNVsZ1jG61nDA==" + }, + "split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", + "dev": true, + "requires": { + "through": "2" + } + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + }, + "dependencies": { + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + } + }, + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "sprintf-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.1.tgz", + "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=" + }, + "sshpk": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.1.tgz", + "integrity": "sha1-Ew9Zde3a2WPx1W+SuaxsUfqfg+s=", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "tweetnacl": "~0.14.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" + }, + "stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", + "dev": true, + "requires": { + "duplexer": "~0.1.1" + } + }, + "stream-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", + "integrity": "sha1-gnfzy+5JpNqrz9tOL0qbXp8snwA=", + "requires": { + "bluebird": "^2.6.2" + }, + "dependencies": { + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + } + } + }, + "string": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/string/-/string-3.3.3.tgz", + "integrity": "sha1-XqIRzZLSKOGEKUmQpsyXs2anfLA=" + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "stringstream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=" + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + }, + "stylus": { + "version": "0.54.5", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.5.tgz", + "integrity": "sha1-QrlWCTHKcJDOhRWnmLqeaqPW3Hk=", + "requires": { + "css-parse": "1.7.x", + "debug": "*", + "glob": "7.0.x", + "mkdirp": "0.5.x", + "sax": "0.5.x", + "source-map": "0.1.x" + }, + "dependencies": { + "glob": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz", + "integrity": "sha1-IRuvr0nlJbjNkyYNFKsTYVKz9Xo=", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.2", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "superagent": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.2.tgz", + "integrity": "sha512-gVH4QfYHcY3P0f/BZzavLreHW3T1v7hG9B+hpMQotGQqurOvhv87GcMCd6LWySmBuf+BDR44TQd0aISjVHLeNQ==", + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.1.1", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.0.5" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, + "superagent-promise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/superagent-promise/-/superagent-promise-1.1.0.tgz", + "integrity": "sha1-uvIti73UOamwfdEPjAj1T+JQNTM=" + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + }, + "sylvester": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/sylvester/-/sylvester-0.0.21.tgz", + "integrity": "sha1-KYexzivS84sNzio0OIiEv6RADqc=" + }, + "symbol-tree": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz", + "integrity": "sha1-rifbOPZgp64uHDt9G8KQgZuFGeY=" + }, + "table": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz", + "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", + "requires": { + "ajv": "^4.7.0", + "ajv-keywords": "^1.0.0", + "chalk": "^1.1.1", + "lodash": "^4.0.0", + "slice-ansi": "0.0.4", + "string-width": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "tar-stream": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.5.5.tgz", + "integrity": "sha512-mQdgLPc/Vjfr3VWqWbfxW8yQNiJCbAZ+Gf6GDu1Cy0bdb33ofyiNGBtAY96jHFhDuivCwgW1H9DgTON+INiXgg==", + "requires": { + "bl": "^1.0.0", + "end-of-stream": "^1.0.0", + "readable-stream": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "term-size": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz", + "integrity": "sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=", + "dev": true, + "requires": { + "execa": "^0.7.0" + } + }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "dev": true + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "through2": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz", + "integrity": "sha1-390BLrnHAOIyP9M084rGIqs3Lac=", + "requires": { + "readable-stream": "~1.0.17", + "xtend": "~3.0.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "xtend": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", + "integrity": "sha1-XM50B7r2Qsunvs2laBEcST9ZZlo=" + } + } + }, + "through2-sink": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/through2-sink/-/through2-sink-1.0.0.tgz", + "integrity": "sha1-XxBruh1zMNrTy6XAqxhjkjJWw5k=", + "requires": { + "through2": "~0.5.1", + "xtend": "~3.0.0" + }, + "dependencies": { + "xtend": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", + "integrity": "sha1-XM50B7r2Qsunvs2laBEcST9ZZlo=" + } + } + }, + "through2-spy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/through2-spy/-/through2-spy-1.2.0.tgz", + "integrity": "sha1-nIkcqcpA4eHkzzHhrFf5TMnSSMs=", + "requires": { + "through2": "~0.5.1", + "xtend": "~3.0.0" + }, + "dependencies": { + "xtend": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", + "integrity": "sha1-XM50B7r2Qsunvs2laBEcST9ZZlo=" + } + } + }, + "timed-out": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", + "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", + "dev": true + }, + "tmi.js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/tmi.js/-/tmi.js-1.2.1.tgz", + "integrity": "sha1-a6NFOKAK3IKqbuSBqbNkn/YRGFM=", + "requires": { + "request": "2.74.0", + "ws": "1.0.1" + }, + "dependencies": { + "bl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz", + "integrity": "sha1-/cqHGplxOqANGeO7ukHER4emU5g=", + "requires": { + "readable-stream": "~2.0.5" + } + }, + "caseless": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", + "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=" + }, + "form-data": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.1.tgz", + "integrity": "sha1-rjFduaSQf6BlUCMEpm13M0de43w=", + "requires": { + "async": "^2.0.1", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.11" + } + }, + "har-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", + "requires": { + "chalk": "^1.1.1", + "commander": "^2.9.0", + "is-my-json-valid": "^2.12.4", + "pinkie-promise": "^2.0.0" + } + }, + "node-uuid": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz", + "integrity": "sha1-sEDrCSOWivq/jTL7HxfxFn/auQc=" + }, + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "qs": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.3.tgz", + "integrity": "sha1-HPyyXBCpsrSDBT/zn138kjOQjP4=" + }, + "readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "request": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.74.0.tgz", + "integrity": "sha1-dpPKdou7DqXIzgjAhKRe+gW4kqs=", + "requires": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "bl": "~1.1.2", + "caseless": "~0.11.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~1.0.0-rc4", + "har-validator": "~2.0.6", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "node-uuid": "~1.4.7", + "oauth-sign": "~0.8.1", + "qs": "~6.2.0", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "~0.4.1" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "tunnel-agent": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", + "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=" + } + } + }, + "tmp": { + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.31.tgz", + "integrity": "sha1-jzirlDjhcxXl29izZX6L+yd65Kc=", + "requires": { + "os-tmpdir": "~1.0.1" + } + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=" + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + } + }, + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "token-stream": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-0.0.1.tgz", + "integrity": "sha1-zu78cXp2xDFvEm0LnbqlXX598Bo=" + }, + "topo": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/topo/-/topo-1.1.0.tgz", + "integrity": "sha1-6ddRYV0buH3IZdsYL6HKCl71NtU=", + "requires": { + "hoek": "2.x.x" + } + }, + "touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, + "requires": { + "abbrev": "1" + } + } + } + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "requires": { + "punycode": "^1.4.1" + } + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.0.tgz", + "integrity": "sha1-X4Y+3Im5bbCQdLrXlHvwkFbKTn0=" + } + } + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=" + }, + "triple-beam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", + "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==", + "dev": true + }, + "tsscmp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.5.tgz", + "integrity": "sha1-fcSjOvcVgatDN9qR2FylQn69mpc=" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "turndown": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-4.0.1.tgz", + "integrity": "sha512-xC83XzYm+yLuQWLBc87s63FLn4+ERdZOxDqlrlvKKWcyL9UFhwtR4hAqmFBKDUQyejRZWU9Fac4vMHomlFboyg==", + "requires": { + "jsdom": "^11.3.0" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "twit": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/twit/-/twit-2.2.9.tgz", + "integrity": "sha1-ZxBXT4FkHaoDeWobS457eNPXVnY=", + "requires": { + "bluebird": "^3.1.5", + "mime": "^1.3.4", + "request": "^2.68.0" + } + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "uc.micro": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz", + "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg==", + "dev": true + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "optional": true + }, + "ultron": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz", + "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po=" + }, + "undefsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz", + "integrity": "sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY=", + "dev": true, + "requires": { + "debug": "^2.2.0" + } + }, + "underscore": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.0.tgz", + "integrity": "sha512-4IV1DSSxC1QK48j9ONFK1MoIAKKkbE8i7u55w2R6IqBqbT7A/iG7aZBCR2Bi8piF0Uz+i/MG1aeqLwl/5vqF+A==", + "optional": true + }, + "unicode": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/unicode/-/unicode-10.0.0.tgz", + "integrity": "sha1-5dUcHbk7bHGguHngsMSvfm/faI4=" + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^0.4.3" + }, + "dependencies": { + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.1", + "to-object-path": "^0.3.0" + } + } + } + }, + "unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "dev": true, + "requires": { + "crypto-random-string": "^1.0.0" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "unzip-response": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz", + "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=", + "dev": true + }, + "upath": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz", + "integrity": "sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==", + "dev": true + }, + "update-notifier": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-2.5.0.tgz", + "integrity": "sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==", + "dev": true, + "requires": { + "boxen": "^1.2.1", + "chalk": "^2.0.1", + "configstore": "^3.0.0", + "import-lazy": "^2.1.0", + "is-ci": "^1.0.10", + "is-installed-globally": "^0.1.0", + "is-npm": "^1.0.0", + "latest-version": "^3.0.0", + "semver-diff": "^2.0.0", + "xdg-basedir": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, + "url-join": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-1.1.0.tgz", + "integrity": "sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg=" + }, + "url-parse-lax": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", + "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=", + "dev": true, + "requires": { + "prepend-http": "^1.0.1" + } + }, + "use": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.0.tgz", + "integrity": "sha512-6UJEQM/L+mzC3ZJNM56Q4DFGLX/evKGRg15UJHGB9X5j5Z3AFbgZvjUh2yq/UJUY4U5dh7Fal++XbNg1uzpRAw==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha1-nHC/2Babwdy/SGBODwS4tJzenp8=", + "requires": { + "os-homedir": "^1.0.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", + "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==" + }, + "v8flags": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz", + "integrity": "sha1-qrGh+jDUX4jdMhFIh1rALAtV5bQ=", + "dev": true, + "requires": { + "user-home": "^1.1.1" + }, + "dependencies": { + "user-home": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz", + "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=", + "dev": true + } + } + }, + "validate-npm-package-license": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.3.tgz", + "integrity": "sha512-63ZOUnL4SIXj4L0NixR3L1lcjO38crAbgrTpl28t8jjrfuiOBL5Iygm+60qPs/KsZGzPNg6Smnc/oY16QTjF0g==", + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validator": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/validator/-/validator-9.4.1.tgz", + "integrity": "sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=" + }, + "w3c-hr-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", + "integrity": "sha1-gqwr/2PZUOqeMYmlimViX+3xkEU=", + "requires": { + "browser-process-hrtime": "^0.1.2" + } + }, + "walkdir": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.0.11.tgz", + "integrity": "sha1-oW0CXrkxvQO1LzCMrtD0D86+lTI=" + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + }, + "whatwg-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.3.tgz", + "integrity": "sha512-jLBwwKUhi8WtBfsMQlL4bUUcT8sMkAtQinscJAe/M4KHCkHuUJAF6vuB0tueNIw4c8ziO6AkRmgY+jL3a0iiPw==", + "requires": { + "iconv-lite": "0.4.19" + } + }, + "whatwg-mimetype": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.1.0.tgz", + "integrity": "sha512-FKxhYLytBQiUKjkYteN71fAUA3g6KpNXoho1isLiLSB3N1G4F35Q5vUxWfKFhBwi5IWF27VE6WxhrnnC+m0Mew==" + }, + "whatwg-url": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.4.0.tgz", + "integrity": "sha512-Z0CVh/YE217Foyb488eo+iBv+r7eAQ0wSTyApi9n06jhcA3z6Nidg/EGvl0UFkg7kMdKxfBzzr+o9JF+cevgMg==", + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.0", + "webidl-conversions": "^4.0.1" + } + }, + "when": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/when/-/when-3.7.8.tgz", + "integrity": "sha1-xxMLan6gRpPoQs3J56Hyqjmjn4I=" + }, + "which": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", + "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" + }, + "widest-line": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.0.tgz", + "integrity": "sha1-AUKk6KJD+IgsAjOqDgKBqnYVInM=", + "dev": true, + "requires": { + "string-width": "^2.1.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=" + }, + "winston": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.3.tgz", + "integrity": "sha512-GYKuysPz2pxYAVJD2NPsDLP5Z79SDEzPm9/j4tCjkF/n89iBNGBMJcR+dMUqxgPNgoSs6fVygPi+Vl2oxIpBuw==", + "requires": { + "async": "~1.0.0", + "colors": "1.0.x", + "cycle": "1.0.x", + "eyes": "0.1.x", + "isstream": "0.1.x", + "stack-trace": "0.0.x" + }, + "dependencies": { + "async": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.0.0.tgz", + "integrity": "sha1-+PwEyjoTeErenhZBr5hXjPvWR6k=" + } + } + }, + "winston-transport": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.3.0.tgz", + "integrity": "sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==", + "dev": true, + "requires": { + "readable-stream": "^2.3.6", + "triple-beam": "^1.2.0" + } + }, + "with": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/with/-/with-5.1.1.tgz", + "integrity": "sha1-+k2qktrzLE6pTtRTyB8EaGtXXf4=", + "requires": { + "acorn": "^3.1.0", + "acorn-globals": "^3.0.0" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=" + } + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "requires": { + "mkdirp": "^0.5.1" + } + }, + "write-file-atomic": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.3.0.tgz", + "integrity": "sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "ws": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.0.1.tgz", + "integrity": "sha1-fQsqLljN3YGQOcKcneZQReGzEOk=", + "requires": { + "options": ">=0.0.5", + "ultron": "1.0.x" + } + }, + "xdg-basedir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", + "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=", + "dev": true + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" + }, + "xml2js": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.17.tgz", + "integrity": "sha1-F76T6q4/O3eTWceVtBlwWogX6Gg=", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "^4.1.0" + }, + "dependencies": { + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + } + } + }, + "xmlbuilder": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz", + "integrity": "sha1-qlijBBoGb5DqoWwvU4n/GfP0YaU=", + "requires": { + "lodash": "^4.0.0" + } + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=" + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, + "yargs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-4.8.1.tgz", + "integrity": "sha1-wMQpJMpKqmsObaFznfshZDn53cA=", + "requires": { + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "lodash.assign": "^4.0.3", + "os-locale": "^1.4.0", + "read-pkg-up": "^1.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^1.0.1", + "which-module": "^1.0.0", + "window-size": "^0.2.0", + "y18n": "^3.2.1", + "yargs-parser": "^2.4.1" + }, + "dependencies": { + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "window-size": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz", + "integrity": "sha1-tDFbtCFKPXBY6+7okuE/ok2YsHU=" + } + } + }, + "yargs-parser": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-2.4.1.tgz", + "integrity": "sha1-hVaN488VD/SfpRgl8DqMiA3cxcQ=", + "requires": { + "camelcase": "^3.0.0", + "lodash.assign": "^4.0.6" + }, + "dependencies": { + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=" + } + } + }, + "zip-stream": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.2.0.tgz", + "integrity": "sha1-qLxF9MG0lpnGuQGYuqyqzbzUugQ=", + "requires": { + "archiver-utils": "^1.3.0", + "compress-commons": "^1.2.0", + "lodash": "^4.8.0", + "readable-stream": "^2.0.0" + } + } + } +} diff --git a/package.json b/package.json index db26f1b..eb1173b 100644 --- a/package.json +++ b/package.json @@ -1,100 +1,162 @@ { - "name": "connector", - "version": "0.0.0", - "description": "Recast.AI Bot Connector", - "main": "src/index.js", + "name": "sap-botconnector", + "version": "1.0.0", + "main": "index.js", "scripts": { - "lint": "eslint src", - "start": "npm run build && cross-env NODE_ENV=production node dist/index.js", - "start-dev": "cross-env NODE_ENV=development nodemon src/index.js --exec babel-node", - "test": "cross-env NODE_ENV=test babel-node src/test.js", + "docs": "./node_modules/.bin/apidoc -i src -o docs", + "lint": "./node_modules/eslint/bin/eslint.js src", + "start": "npm run build && NODE_ENV=production node dist/index.js", + "start:dev": "NODE_ENV=development nodemon src/index.js --exec babel-node", + "start:dev:debug": "NODE_ENV=development nodemon src/index.js --inspect --exec babel-node", + "test": "NODE_ENV=test node ./node_modules/mocha/bin/mocha --recursive --require babel-core/register --exit --timeout 2500 'src/channel_integrations/**/test/**/*.js' 'test/**/*.js'", + "test:debug": "NODE_ENV=test node ./node_modules/mocha/bin/mocha --inspect-brk --recursive --require babel-core/register --exit 'src/channel_integrations/**/test/**/*.js' 'test/**/*.js'", + "test:coverage": "NODE_ENV=test ./node_modules/.bin/nyc ./node_modules/.bin/mocha --recursive --exit --timeout 10000 'src/channel_integrations/**/test/**/*.js' 'test/**/*.js'", "build": "babel src --out-dir dist", - "deploy": "./deploy.sh", - "doc": "apidoc -i src/ -o doc/" + "build:production": "NODE_ENV=production babel src --out-dir dist" }, - "repository": { - "type": "git", - "url": "git@git.recast.ai:recast/connector.git" + "license": "MIT", + "apidoc": { + "name": "SAP Bot Connector API Documentation", + "version": "1.0.0", + "title": "SAP Bot Connector API Documentation", + "description": "" }, - "author": "Recast (https://recast.ai)", - "license": "ISC", "eslintConfig": { "extends": [ "zavatta" ], + "env": { + "mocha": true + }, + "globals": { + "models": false, + "controllers": false, + "services": false, + "config": false, + "Map": false, + "Promise": false + }, "rules": { + "indent": [ + "error", + 2 + ], + "max-len": [ + "warn", + { + "code": 100 + } + ], + "no-case-declarations": 0, "no-sync": 0, - "no-undef": 0, - "id-length": 0, + "prefer-spread": 0, "camelcase": 0, + "id-length": 0, "no-inline-comments": 0, - "max-nested-callbacks": 0 + "guard-for-in": 0, + "no-useless-call": 0, + "require-await": 0, + "no-undefined": 0 } }, "babel": { "presets": [ - "es2015", - "stage-0" + "stage-0", + "env" ], + "env": { + "test": { + "plugins": [ + "istanbul" + ] + } + }, "plugins": [ - [ - "coverage", - { - "only": "src/*/" - } - ], + "transform-class-properties", "transform-runtime" ] }, - "apidoc": { - "name": "Bot Connector API Documentation", - "version": "0.0.0", - "title": "Bot Connector API Documentation", - "description": "" + "nyc": { + "reporter": [ + "html", + "text-summary" + ], + "require": [ + "babel-register" + ], + "sourceMap": false, + "instrument": false }, "dependencies": { - "@slack/client": "^3.6.0", - "babel-plugin-transform-runtime": "^6.15.0", + "alexa-verifier": "^1.0.0", + "archiver": "^1.3.0", + "ask-sdk": "^2.0.6", + "babel-core": "^6.23.1", + "babel-plugin-transform-class-properties": "^6.24.1", + "babel-preset-env": "^1.6.0", + "babel-preset-stage-0": "^6.24.1", + "bluebird": "^3.5.1", "blueimp-md5": "^2.5.0", - "body-parser": "^1.15.2", - "botbuilder": "^3.8.4", + "body-parser": "^1.18.2", + "botbuilder": "3.12.0", "callr": "^1.0.0", - "chai-spies": "^0.7.1", - "cors": "^2.8.1", - "cross-env": "3.1.3", + "chai": "^4.1.2", + "chai-http": "^4.0.0", + "content-type": "^1.0.2", "crypto": "^0.0.3", - "eslint": "^3.7.1", + "eslint": "^3.10.2", "eslint-config-zavatta": "^4.2.0", - "express": "^4.14.0", - "file-type": "^5.2.0", + "express": "^4.16.2", + "fbgraph": "^1.4.1", + "file-type": "^4.3.0", "filter-object": "^2.1.0", + "http": "^0.0.0", + "intercom-client": "^2.9.1", "is_js": "^0.9.0", - "istanbul": "^0.4.5", - "lodash": "^4.16.4", - "mocha": "^3.1.2", - "mongoose": "^4.6.3", - "node-sparky": "^4.0.8", + "jsonschema": "^1.1.1", + "jsonwebtoken": "^8.1.0", + "kue": "^0.11.6", + "lodash": "^4.17.2", + "login-with-amazon": "^0.0.3", + "md-recast": "^0.0.2", + "mocha": "^5.1.0", + "moment": "^2.18.1", + "mongoose": "^4.6.8", + "morgan": "^1.7.0", + "node-sparky": "^4.0.7", + "nodemailer": "^4.4.0", + "random-words": "^0.0.1", + "raw-body": "^2.2.0", "recursive-readdir": "^2.1.0", - "sinon": "^1.17.6", - "superagent": "^2.3.0", + "request": "2.81.0", + "slug": "^0.9.1", + "slugify": "^1.1.0", + "superagent": "^3.0.0", "superagent-promise": "^1.1.0", + "tmi.js": "^1.2.1", "tmp": "^0.0.31", "tsscmp": "^1.0.5", - "twit": "git://github.com/dbousque/twit.git", - "uuid": "^3.0.1" + "turndown": "^4.0.1", + "twit": "^2.2.5", + "uuid": "^3.0.1", + "winston": "^2.4.2" }, "devDependencies": { - "apidoc": "^0.16.1", - "babel-cli": "^6.16.0", - "babel-eslint": "^7.0.0", - "babel-plugin-coverage": "^1.0.0", - "babel-preset-es2015": "^6.16.0", - "babel-preset-stage-0": "^6.16.0", - "chai": "^3.5.0", - "chai-http": "^3.0.0", - "mocha": "^3.1.1", - "mock-require": "^1.3.0", - "nock": "^8.1.0", - "nodemon": "^1.11.0" + "apidoc": "0.17.7", + "babel-cli": "6.18.0", + "babel-eslint": "^7.1.1", + "babel-generator": "6.19.0", + "babel-plugin-istanbul": "^4.1.6", + "babel-plugin-transform-runtime": "^6.15.0", + "babel-runtime": "^6.18.0", + "babel-traverse": "6.19.0", + "babel-types": "6.19.0", + "expect.js": "^0.3.1", + "istanbul": "^0.4.5", + "nock": "^9.0.6", + "nodemon": "^1.17.5", + "nyc": "^11.7.1", + "qs": "^6.5.2", + "should": "^11.2.0" } } diff --git a/resources/kik/kik.png b/resources/kik/kik.png deleted file mode 100644 index 6f3f027e61986001c587b515c99ae1344b6b31f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 93491 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 diff --git a/resources/messenger/messenger.jpg b/resources/messenger/messenger.jpg deleted file mode 100644 index 5b16d352b46096616eb547b5bb9ac5df0b674054..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76189 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 diff --git a/resources/slack/slack.jpg b/resources/slack/slack.jpg deleted file mode 100644 index 9ace36b72dfabcd578cc6f12c07acdf2fe8e1cef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95217 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

Q^p;nv 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 GPyY|}K=0%L diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..7988e65 --- /dev/null +++ b/src/app.js @@ -0,0 +1,92 @@ +import express from 'express' +import contentType from 'content-type' +import bodyParser from 'body-parser' +import morgan from 'morgan' +import rawBody from 'raw-body' +import { logger } from './utils' +import mongoose from 'mongoose' +import { createRouter } from './routes' +import config from '../config' + +function createApplication () { + const app = express() + + // Enable Cross Origin Resource Sharing + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Headers', '*, Content-Type, Authorization') + res.header('Access-Control-Allow-Methods', 'GET, DELETE, POST, PUT, OPTIONS') + res.header('Access-Control-Max-Age', '86400') + res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate') + res.header('Expires', '-1') + res.header('Pragma', 'no-cache') + next() + }) + + // stock a buffer containing the raw body in req.rawBody + app.use(async (req, res, next) => { + next() // eslint-disable-line callback-return + + if (req.method !== 'POST') { return } + + try { + let encoding = 'utf-8' + try { + encoding = contentType.parse(req).parameters.charset + } catch (e) {} // eslint-disable-line + req.rawBody = await rawBody(req, { + length: req.headers['content-length'], + limit: '1mb', + encoding, + }) + } catch (err) { + logger.error(`Error while getting raw body from request: ${err}`) + } + }) + + // Enable auto parsing of json content + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: true })) + + // Enable middleware logging + app.use(morgan('dev')) + return app +} + +function connectMongoDB () { + return new Promise((resolve, reject) => { + logger.info('Connecting to MongoDB') + // Use native Promise with mongoose + mongoose.Promise = global.Promise + + let dbUrl = 'mongodb://' + if (config.db.username) { + dbUrl = `${dbUrl}${config.db.username}:${config.db.password}@` + } + const path = `${config.db.dbName}?ssl=${config.db.ssl || 'false'}` + dbUrl = `${dbUrl}${config.db.host}:${config.db.port}/${path}` + mongoose.connect(dbUrl) + const db = mongoose.connection + db.on('error', reject) + db.once('open', () => { + resolve(db) + }) + }) +} + +function startExpressApp () { + return new Promise((resolve) => { + logger.info('Starting App') + const app = createApplication() + createRouter(app) + app.listen(config.server.port, () => { + app.emit('ready') + resolve(app) + }) + }) +} + +export async function startApplication () { + await connectMongoDB() + return startExpressApp() +} diff --git a/src/channel_integrations/abstract_channel_integration.js b/src/channel_integrations/abstract_channel_integration.js new file mode 100644 index 0000000..f6150e2 --- /dev/null +++ b/src/channel_integrations/abstract_channel_integration.js @@ -0,0 +1,312 @@ +import { noop } from '../utils' +import config from '../../config' + +export class NotImplementedError extends Error { + constructor (method) { + super(`Implement ${method} in class AbstractChannelIntegration`) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, NotImplementedError) + } + + this.method = method + } +} + +/* eslint no-unused-vars: 0 */ // --> OFF for abstract methods + +/** + * Abstract base class for custom channel integrations. + */ +export class AbstractChannelIntegration { + + /** + * Can be overwritten by subclasses to customize the HTTP response that + * is sent for a request to the channel's webhook. + * @param {http.IncomingMessage} req HTTP request + * @param {http.ServerResponse} res HTTP response + * @param {Object} context Context from {@link populateMessageContext} + * @param {Object[]} botResponse The bot's response to the request's input message + */ + finalizeWebhookRequest (req, res, context, botResponse) { + res.status(200).json({ results: null, message: 'Message successfully received' }) + } + + /** + * Validates properties of the channel model created or updated using the JSON request body + * of the respective HTTP endpoint. + * @param {Channel} channel An instance of the channel model + * @throws {BadRequestError} Validation of channel object failed + */ + validateChannelObject (channel) { + // NOOP by default + } + + /** + * Gets called before the channel model gets persisted into the database. + * @param {Channel} channel An instance of the channel model + * @param {http.IncomingMessage} req HTTP request + * @return {Promise} Promise which resolves when all operations executed successfully + */ + beforeChannelCreated (channel, req) { return noop() } + + /** + * Is called after the channel model is persisted in the database. + * @param {Channel} channel An instance of the channel model + * @param {http.IncomingMessage} req HTTP request + * @return {Promise} Promise which resolves when all operations executed have been executed + */ + afterChannelCreated (channel, req) { return noop() } + + /** + * Gets called after the channel model was updated in the database. + * @param {Channel} channel The updated channel model + * @param {Channel} oldChannel The old channel model + * @return {Promise} Promise which resolves when all operations executed successfully + */ + afterChannelUpdated (channel, oldChannel) { return noop() } + + /** + * Gets called before the channel model get deleted in the database. + * @param {Channel} channel The deleted channel model + * @throws {ForbiddenError} Authentication failed + * @return {Promise} Promise which resolves when all operations executed successfully + */ + beforeChannelDeleted (channel) { return noop() } + + /** + * Gets called after the channel model was deleted in the database. + * @param {Channel} channel The deleted channel model + * @return {Promise} Promise which resolves when all operations executed successfully + */ + afterChannelDeleted (channel) { return noop() } + + /** + * Defines the HTTP methods that a caller can use to send messages to a channel's webhook. + * @returns {string[]} List of HTTP methods + */ + webhookHttpMethods () { + return ['POST'] + } + + /** + * Generates a URL for the channel's webhook. This is called during the creation of + * a new channel. Only override this method if you need to change your channel's webhook URL + * for a particular reason. + * @param channel An instance of the channel model before being persisted + * @return {string} A well-formed URL + */ + buildWebhookUrl (channel) { + return `${config.base_url}/v1/webhook/${channel._id}` + } + + /** + * Validates parameters of the request object for webhook subscriptions. + * @param {http.IncomingMessage} req HTTP request + * @param {http.ServerResponse} res HTTP response + * @param {Channel} channel An instance of the channel model + * @return {Promise} Promise which resolves when parameter validation succeeds + */ + validateWebhookSubscriptionRequest (req, res, channel) { + throw new NotImplementedError('validateWebhookSubscriptionRequest') + } + + /** + * Authenticates an HTTP request made against the channel's webhook. + * @param {http.IncomingMessage} req HTTP request + * @param {http.ServerResponse} res HTTP response + * @param {Channel} channel An instance of the channel model + * @throws {ForbiddenError} Authentication failed + * @return {Promise} Promise which resolves when request authentication succeeds + */ + authenticateWebhookRequest (req, res, channel) { return noop() } + + /** + * Gets called before an HTTP request to the channel's webhook is processed. + * @param {http.IncomingMessage} req HTTP request + * @param {http.ServerResponse} res HTTP response + * @param {Channel} channel An instance of the channel model + * @return {Promise} Promise which resolves when all operations succeed + */ + onWebhookCalled (req, res, channel) { + return channel + } + + /** + * Retrieves context information from the HTTP request, such as chatId, senderId, + * and channel-specific data. + * @param {http.IncomingMessage} req HTTP request + * @param {http.ServerResponse} res HTTP response + * @param {Channel} channel An instance of the channel model + * @return {Object} The context object for further message processing + */ + populateMessageContext (req, res, channel) { + throw new NotImplementedError('populateMessageContext') + } + + /** + * Reads the unparsed message from a request sent to the channel's webhook. + * @param {Channel} channel An instance of the channel model + * @param {http.IncomingMessage} req HTTP request + * @param {Object} context Context from {@link populateMessageContext} + * @return {Object} The raw message + */ + getRawMessage (channel, req, context) { + return req.body + } + + /** + * Gets called when the bot is ready to indicate that it is "typing". This hook can be + * used to activate "isTyping" indicators in any external services. + * @see populateMessageContext + * @param {Channel} channel An instance of the channel model + * @param {Object} context Context from {@link populateMessageContext} + * @return {Promise} Promise which resolves when "isTyping" request was successful + */ + onIsTyping (channel, context) { return noop() } + + /** + * Can be implemented to update the current conersation based on a received message. + * @param {Conversation} conversation The conversation to be updated + * @param {Message} message The received message + * @return {Conversation} The conversation with update properties + */ + updateConversationContextFromMessage (conversation, message) { + return conversation + } + + /** + * Parses an HTTP request sent to the channel's webhook and extracts a message object. + * The returned message object needs to be in the bot connector's format. + * @param {Conversation} conversation The message's conversation + * @param {Object} message The raw message returned from {@link getRawMessage} + * @param {Object} context Context from {@link populateMessageContext} + * @return {Object} The well-formatted message object + */ + parseIncomingMessage (conversation, message, context) { return noop() } + + /** + * Parses an HTTP request sent to the channel's webhook and extracts a memory object. + * The memory will be sent to the bot. + * @param {Object} message The raw message returned from {@link getRawMessage} + * @param {Object} context Context from {@link populateMessageContext} + * @return {Object} The well-formatted message object + */ + getMemoryOptions (message, context) { return { memory: {}, merge: true } } + + /** + * Transforms a bot's response into a format that can be understood by the channel' API. + * @param {Conversation} conversation The message's conversation + * @param {Object} message Message in bot builder's format + * @param {Object} context Context from {@link populateMessageContext} + * @return {Object} Message in channel API's format + */ + formatOutgoingMessage (conversation, message, context) { return message } + + /** + * Sends response message to the channel. + * @param {Conversation} conversation The message's conversation + * @param {Object} message The pre-formatted message returned from {@link parseIncomingMessage} + * @param {Object} context Context from {@link populateMessageContext} + * @return {Promise} Promise which resolves when message was sent successfully + */ + sendMessage (conversation, message, context) { return noop() } + + /** + * Parses a participant's display name, which can be either a phone number or a user name. + * @example + * parseParticipantDisplayName (participant) { + * const externalParticipant = participant.data + * const displayName = `${externalParticipant.first} ${externalParticipant.last}` + * return { userName: displayName } + * } + * @example + * parseParticipantDisplayName (participant) { + * const externalParticipant = participant.data + * return { phoneNumber: externalParticipant.mobile } + * } + * @param {Participant} participant Complete Participant object + * saved in {@link populateParticipantData} + * @return {Object} An object containing either a `phoneNumber` or a `userName` field + */ + parseParticipantDisplayName (participant) { + return ({}) + } + + /** + * Downloads additional information about a participant and adds it to the database model. + * The downloaded information can be an arbitrary object and is expected to be saved + * as the `participant.data` property. + * @example + * const externalData = downloadParticipantFromAPI() + * participant.data = externalData + * participant.markModified('data') + * // Persist model and return Promise + * return participant.save() + * @param {Participant} participant Participant model to be used for persisting the data + * @param {Channel} channel An instance of the channel model + * @return {Promise} Promise which resolves if data has been downloaded and saved successfully + */ + populateParticipantData (participant, channel) { + return participant + } + + /* Call to send a message array + to a bot. If this method returns + true, `sendMessage` won't be called */ + sendMessages () { + return false + } + + /* Check shared webhook validity for certain channels (Messenger) */ + onSharedWebhookChecking () { + throw new NotImplementedError('onSharedWebhookChecking') + } + + /* Get unique model field / value pair to give a means to + identify a channel when a message is received by + a shared webhook endpoint */ + getIdPairsFromSharedWebhook () { + throw new NotImplementedError('getIdPairsFromSharedWebhook') + } + + /** + * set a get started button to the appropriate channel + * @param {Channel} an instance of the channel model + * @param {Value} the get started button's value + * @returns {Promise} a promise that resolves when the button is set + */ + setGetStartedButton (channel, value, connector) { + return noop() + } + + /** + * remove a get started button from a channel + * @param {Channel} an instance of the channel model + * @returns {Promise} a promise that resolves when the button is successfully removed + */ + deleteGetStartedButton (channel) { + return noop() + } + + /** + * Set a persistent menu to a specific channel + * @param {Channel} An instance of the Channel model + * @param {Menus} array of PersistentMenu instances + * @returns {Promise} a promise that resolves when the persistent menu is successfully set + */ + setPersistentMenu (channel, menus) { + return noop() + } + + /** + * Remove a peristent menu form a specific channel + * @param {Channel} An instance of the Channel model + * @returns {Promise} + */ + deletePersistentMenu (channel) { + return noop() + } +} + +export default AbstractChannelIntegration diff --git a/src/channel_integrations/amazon_alexa/channel.js b/src/channel_integrations/amazon_alexa/channel.js new file mode 100644 index 0000000..eab972e --- /dev/null +++ b/src/channel_integrations/amazon_alexa/channel.js @@ -0,0 +1,368 @@ +import lwa from 'login-with-amazon' +import _ from 'lodash' +import { SkillBuilders } from 'ask-sdk' + +import config from '../../../config' +import { logger, AppError, BadRequestError } from '../../utils' +import AbstractChannelIntegration from '../abstract_channel_integration' +import AlexaSMAPI from './sdk.js' + +/* + * getWebhookUrl: default + */ +export default class AmazonAlexa extends AbstractChannelIntegration { + + static REQUEST_LAUNCH = 'LaunchRequest' + static SESSION_ENDED_REQUEST = 'SessionEndedRequest' + static INTENT_REQUEST = 'IntentRequest' + static CATCH_ALL_INTENT = 'CATCH_ALL_INTENT' + static CATCH_ALL_SLOT = 'CATCH_ALL_SLOT' + + // Special Alexa Intent types + static CONVERSATION_START = 'CONVERSATION_START' + static CONVERSATION_END = 'CONVERSATION_END' + + // Special Memory flag to end conversation + static END_CONVERSATION = 'END_CONVERSATION' + + static supportedLocales = AlexaSMAPI.locales + + // [START] Inherited from AbstractChannelIntegration + + async beforeChannelCreated (channel) { + // Exchange the OAuth Code for Access Token and Refresh Token + const { access_token, refresh_token } = await lwa.getAccessTokens( + channel.oAuthCode, config.amazon_client_id, config.amazon_client_secret) + channel.oAuthTokens = { access_token, refresh_token } + await channel.save() + } + + async beforeChannelDeleted (channel) { + if (!channel.skillId) { + // Don't do anything if it has not been deployed to Amazon Alexa + return + } + + try { + await this.smapiCallWithAutoTokenRefresh(channel, 'deleteSkill', channel.skillId) + } catch (sdkError) { + const { message, status } = sdkError + if (status === 404) { + // If the Skill has been deleted in the Amazon Alexa Console, ignore the error + return + } + throw new AppError(message, null, status) + } + } + + async parseIncomingMessage (conversation, message, opts) { + try { + // Workaround for passing on the Response Builder since the ASK SDK wraps it into an object + // eslint-disable-next-line max-len + // https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs/blob/2.0.x/ask-sdk-core/lib/skill/Skill.ts#L93 + const alexaResponse = await AmazonAlexa.skill.invoke(message) + + // Extract msg (for Bot Builder) and responseBuilder (for Alexa response) + const { response: { msg, responseBuilder } } = alexaResponse + // Delete the current response object + delete alexaResponse.response + _.set(opts, 'responseBuilder', responseBuilder) + _.set(opts, 'alexaResponseTemplate', alexaResponse) + return msg + } catch (err) { + logger.error(`[Amazon Alexa] Error invoking skill: ${err}`) + throw err + } + } + + populateMessageContext (req) { + const chatId = _.get(req, 'body.session.sessionId') + const senderId = _.get(req, 'body.session.user.userId') + if (_.isNil(chatId) || _.isNil(senderId)) { + throw new BadRequestError('Invalid sessionId or userId', { chatId, senderId }) + } + return { chatId, senderId } + } + + finalizeWebhookRequest (req, res, context, replies) { + if (!_.isEmpty(replies)) { + return res.status(200).json(_.head(replies)) + } + const emptyResponse = _.get(context, 'alexaResponseTemplate', {}) + _.set(emptyResponse, 'response', {}) + res.status(200).send(emptyResponse) + } + + formatOutgoingMessage (conversation, message, context) { + const { attachment: { type, content } } = message + + if (!_.includes(['text', 'card'], type)) { + throw Error(`[Amazon Alexa] Unsupported response type: ${type}`) + } + + // Special memory flag to end Alexa conversation (session) + const shouldEndSession = _.get(context, ['memory', AmazonAlexa.END_CONVERSATION], false) + + // Prepared Alexa response format from Skill invocation + const alexaResponseTemplate = _.get(context, 'alexaResponseTemplate', {}) + + let response = {} + try { + if (type === 'text') { + response = context.responseBuilder + .speak(content) + .withShouldEndSession(shouldEndSession) + .getResponse() + } + if (type === 'card') { + const { title: cardTitle, subtitle: cardContent, imageUrl: largeImageUrl } = content + + if (largeImageUrl) { + // 1. case: Standard Card with image + response = context.responseBuilder + .speak(content) + .withStandardCard(cardTitle, cardContent, largeImageUrl) + .withShouldEndSession(false) + .getResponse() + } else if (cardContent === 'account_linking') { + // 2. case: Account Linking + response = context.responseBuilder + .speak(content) + .withLinkAccountCard() + .withShouldEndSession(false) + .getResponse() + } + // Default case: Simple Card + response = context.responseBuilder + .speak(content) + .withSimpleCard(cardTitle, cardContent) + .withShouldEndSession(false) + .getResponse() + } + } catch (err) { + logger.error(`[Amazon Alexa] Error creating outgoing message: ${err}`) + throw err + } + _.set(alexaResponseTemplate, 'response', response) + return alexaResponseTemplate + } + + // [END] Inherited from AbstractChannelIntegration + + static LaunchRequestHandler = { + canHandle: (input) => input.requestEnvelope.request.type === AmazonAlexa.REQUEST_LAUNCH, + handle: (input) => { + return { + msg: { + attachment: { + content: AmazonAlexa.CONVERSATION_START, + type: AmazonAlexa.CONVERSATION_START, + }, + }, + responseBuilder: input.responseBuilder, + } + }, + } + + static SessionEndedRequestHandler = { + canHandle: (input) => input.requestEnvelope.request.type === AmazonAlexa.SESSION_ENDED_REQUEST, + handle: (input) => { + return { + msg: { + attachment: { + content: null, + type: AmazonAlexa.CONVERSATION_END, + }, + }, + responseBuilder: input.responseBuilder, + } + }, + } + + static CatchAllHandler = { + canHandle: (input) => input.requestEnvelope.request.type === AmazonAlexa.INTENT_REQUEST + && input.requestEnvelope.request.intent.name === AmazonAlexa.CATCH_ALL_INTENT, + handle: (input) => { + return { + msg: { + attachment: { + content: _.get(input, ['requestEnvelope', 'request', 'intent', 'slots', + AmazonAlexa.CATCH_ALL_SLOT, 'value']), + type: 'text', + }, + }, + responseBuilder: input.responseBuilder, + } + }, + } + + static skill = SkillBuilders.custom() + .addRequestHandlers(AmazonAlexa.LaunchRequestHandler, + AmazonAlexa.SessionEndedRequestHandler, + AmazonAlexa.CatchAllHandler) + .create() + + async refreshAccessToken (channel) { + const { refresh_token } = channel.oAuthTokens + try { + const response = await lwa.refreshAccessToken( + refresh_token, config.amazon_client_id, config.amazon_client_secret) + channel.oAuthTokens.access_token = response.access_token + await channel.save() + } catch (err) { + logger.error(`[Amazon Alexa] Error refreshing access token: ${err}`) + throw err + } + } + + async smapiCallWithAutoTokenRefresh (channel, func, ...params) { + let smapi = new AlexaSMAPI(channel.oAuthTokens.access_token) + try { + const response = await smapi[func](...params) + return response + } catch (err) { + if (err.status === 401) { + await this.refreshAccessToken(channel) + smapi = new AlexaSMAPI(channel.oAuthTokens.access_token) + return smapi[func](...params) + } + throw err + } + } + + async getVendors (channel) { + try { + const vendors = await this.smapiCallWithAutoTokenRefresh(channel, 'getVendors') + return vendors + } catch (sdkError) { + const { message, status } = sdkError + throw new AppError(message, null, status) + } + } + + async deploy (channel, { vendor = null, locales = [], isUpdate = false }) { + channel.invocationName = this.convertInvocationName(channel.invocationName) + channel.vendor = vendor + channel.locales = locales + + const skillManifest = this.createSkillManifest(channel) + const interactionModel = this.createInteractionModel(channel.invocationName) + + try { + if (isUpdate) { + await this.smapiCallWithAutoTokenRefresh( + channel, 'updateSkill', channel.skillId, skillManifest) + } else { + const { body: { skillId } } = await this.smapiCallWithAutoTokenRefresh( + channel, 'createSkill', channel.vendor, skillManifest) + channel.skillId = skillId + } + + for (const locale of channel.locales) { + await this.smapiCallWithAutoTokenRefresh( + channel, 'updateInteractionModel', channel.skillId, locale, interactionModel) + } + await channel.save() + } catch (sdkError) { + const { message, status } = sdkError + throw new AppError(message, null, status) + } + } + + createPublishingInformationByLocal (channel) { + const publishingInformation = {} + for (const locale of channel.locales) { + publishingInformation[locale] = { + name: channel.slug, + summary: 'This is an Alexa custom skill using SAP Conversational AI.', + description: 'This skill can leverage all the power of SAP Conversational AI.', + examplePhrases: [ + `Alexa, ask ${channel.invocationName}.`, + `Alexa, begin ${channel.invocationName}.`, + `Alexa, do ${channel.invocationName}.`, + ], + } + } + return publishingInformation + } + + createPrivacyAndComplianceByLocal (channel) { + const privacyAndCompliance = {} + for (const locale of channel.locales) { + privacyAndCompliance[locale] = { + privacyPolicyUrl: 'http://www.myprivacypolicy.sampleskill.com', + termsOfUseUrl: 'http://www.termsofuse.sampleskill.com', + } + } + return privacyAndCompliance + } + + createSkillManifest (channel) { + return { + publishingInformation: { + locales: this.createPublishingInformationByLocal(channel), + isAvailableWorldwide: false, + category: 'NOVELTY', + distributionCountries: ['US'], + }, + apis: { + custom: { + endpoint: { + sslCertificateType: 'Wildcard', + uri: channel.webhook, + }, + }, + }, + manifestVersion: '1.0', + permissions: [], + privacyAndCompliance: { + allowsPurchases: false, + usesPersonalInfo: false, + isChildDirected: false, + isExportCompliant: false, + containsAds: false, + locales: this.createPrivacyAndComplianceByLocal(channel), + }, + } + } + + createInteractionModel (invocationName) { + return { + languageModel: { + invocationName, + intents: [ + { + name: 'CATCH_ALL_INTENT', + slots: [ + { + name: 'CATCH_ALL_SLOT', + type: 'CATCH_ALL_SLOT_TYPE', + }, + ], + samples: [ + '{CATCH_ALL_SLOT}', + ], + }, + ], + types: [ + { + name: 'CATCH_ALL_SLOT_TYPE', + values: [ + { + name: { + value: 'CATCH_ALL_SLOT_VALUE', + }, + }, + ], + }, + ], + }, + } + } + + convertInvocationName (invocationName) { + // Invocation name must start with a letter and can only contain lower case + // letters, spaces, apostrophes, and periods. + return invocationName.replace(/[^a-zA-Z ]/g, '').toLowerCase() + } +} diff --git a/src/channel_integrations/amazon_alexa/controller.js b/src/channel_integrations/amazon_alexa/controller.js new file mode 100644 index 0000000..18e4185 --- /dev/null +++ b/src/channel_integrations/amazon_alexa/controller.js @@ -0,0 +1,66 @@ +import { Channel, Connector } from '../../models' +import { logger } from '../../utils' +import { NotFoundError } from '../../utils/errors' +import AmazonAlexaChannel from './channel.js' + +import { renderOk } from '../../utils/responses' + +export default class AmazonController { + + static async loadChannel (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const channel_slug = req.params.channel_slug + + if (!connector) { + throw new NotFoundError('Connector') + } + + // Load the existing Channel model object + let channel + try { + channel = await Channel.findOne({ + slug: channel_slug, + connector: connector._id, + isActive: true, + }) + } catch (err) { + logger.error(`[Amazon Alexa] Error loading Channel model: ${err}`) + res.status(500).send(err) + } + + if (!channel) { + throw new NotFoundError('Channel') + } + return channel + } + + static async getVendors (req, res) { + const channel = await AmazonController.loadChannel(req, res) + try { + const channelIntegration = new AmazonAlexaChannel() + const vendors = await channelIntegration.getVendors(channel) + return renderOk(res, vendors.body) + } catch (err) { + logger.error(`[Amazon Alexa] Error retrieving Vendors: ${err} (${JSON.stringify(err.body)})`) + logger.error(err.stack) + res.status(500).send(err) + } + } + + static async deploy (req, res) { + const channel = await AmazonController.loadChannel(req, res) + try { + const channelIntegration = new AmazonAlexaChannel() + await channelIntegration.deploy(channel, req.body) + return renderOk(res) + } catch (err) { + logger.error(`[Amazon Alexa] Error deploying: ${err} (${JSON.stringify(err.body)})`) + logger.error(err.stack) + res.status(500).send(err) + } + } + + static getSupportedLocales (req, res) { + return renderOk(res, AmazonAlexaChannel.supportedLocales) + } +} diff --git a/src/channel_integrations/amazon_alexa/index.js b/src/channel_integrations/amazon_alexa/index.js new file mode 100644 index 0000000..303fbfb --- /dev/null +++ b/src/channel_integrations/amazon_alexa/index.js @@ -0,0 +1,4 @@ +import channel from './channel' +import routes from './routes' + +module.exports = { channel, routes, identifiers: ['amazonalexa'] } diff --git a/src/channel_integrations/amazon_alexa/routes.js b/src/channel_integrations/amazon_alexa/routes.js new file mode 100644 index 0000000..b8de320 --- /dev/null +++ b/src/channel_integrations/amazon_alexa/routes.js @@ -0,0 +1,25 @@ +import controller from './controller' + +export default [ + { + method: 'GET', + path: ['/connectors/:connectorId/channels/:channel_slug/amazon/vendors'], + validators: [], + authenticators: [], + handler: controller.getVendors, + }, + { + method: 'GET', + path: ['/connectors/:connectorId/channels/:channel_slug/amazon/locales'], + validators: [], + authenticators: [], + handler: controller.getSupportedLocales, + }, + { + method: 'POST', + path: ['/connectors/connectorId/channels/:channel_slug/amazon/deploy'], + validators: [], + authenticators: [], + handler: controller.deploy, + }, +] diff --git a/src/channel_integrations/amazon_alexa/sdk.js b/src/channel_integrations/amazon_alexa/sdk.js new file mode 100644 index 0000000..53d467d --- /dev/null +++ b/src/channel_integrations/amazon_alexa/sdk.js @@ -0,0 +1,129 @@ +import Promise from 'bluebird' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' +import _ from 'lodash' +import assert from 'assert' + +const agent = superagentPromise(superagent, Promise) + +export class AlexaSMAPIError extends Error { + constructor (message = '', body = {}, status = 500) { + super(message) + this.constructor = AlexaSMAPIError + // eslint-disable-next-line no-proto + this.__proto__ = AlexaSMAPIError.prototype + this.name = this.constructor.name + Error.captureStackTrace(this, this.constructor) + this.status = status + this.message = _.get(body, 'message', '') + } +} + +export default class AlexaSMAPI { + + static locales = ['en-US', 'en-GB', 'de-DE', 'en-CA', 'en-IN', 'en-AU', 'ja-JP'] + + constructor (accessToken, + api = 'https://api.amazonalexa.com') { + this.accessToken = accessToken + this.api = api + } + + async _callApi ({ url, method = 'GET', query = {}, payload = {} }) { + try { + const request = agent(method, url) + .set('Authorization', this.accessToken) + .query(query) + + if (!_.isEmpty(payload)) { + request.send(payload) + } + const { body, status } = (await request.end()) + return { body, status } + } catch (err) { + throw new AlexaSMAPIError(err.message, err.response.body, err.response.statusCode) + } + } + + async getSkill (skillId, stage = 'development') { + const url = `${this.api}/v1/skills/${skillId}/stages/${stage}/manifest` + return this._callApi({ url }) + } + + async createSkill (vendorId, manifest) { + const url = `${this.api}/v1/skills` + const method = 'POST' + const payload = { vendorId, manifest } + return this._callApi({ url, method, payload }) + } + + async updateSkill (skillId, manifest, stage = 'development') { + const url = `${this.api}/v1/skills/${skillId}/stages/${stage}/manifest` + const method = 'PUT' + const payload = { manifest } + return this._callApi({ url, method, payload }) + } + + async getSkillStatus (skillId, resource = []) { + const url = `${this.api}/v1/skills/${skillId}/status` + const query = { resource } + return this._callApi({ url, query }) + } + + async listSkills ({ vendorId, skillId = [], maxResults, nextToken }) { + try { + if (!_.isEmpty(skillId)) { + assert(_.isUndefined(maxResults)) + assert(_.isUndefined(nextToken)) + } + if (_.isArray(skillId)) { + assert(skillId.length <= 10) + } + if (!_.isUndefined(maxResults)) { + assert(maxResults <= 50) + } + } catch (err) { + throw new AlexaSMAPIError( + `Parameters don't fulfill requirements: + - 'maxResults' and 'nextToken' must not be used when 'skillId' is in use. + - One request can include up to 10 'skillId' values. + - The value of maxResults must not exceed 50.` + ) + } + const url = `${this.api}/v1/skills` + const query = { vendorId, skillId, maxResults, nextToken } + return this._callApi({ url, query }) + } + + async deleteSkill (skillId) { + const url = `${this.api}/v1/skills/${skillId}` + const method = 'DELETE' + return this._callApi({ url, method }) + } + + async getInteractionModel (skillId, locale, stage = 'development') { + const url = `${this.api}/v1/skills/${skillId}/stages/${stage}` + + `/interactionModel/locales/${locale}` + return this._callApi({ url }) + } + + async headInteractionModel (skillId, locale, stage = 'development') { + const url = `${this.api}/v1/skills/${skillId}/stages/${stage}` + + `/interactionModel/locales/${locale}` + const method = 'HEAD' + return this._callApi({ url, method }) + } + + async updateInteractionModel (skillId, locale, interactionModel, stage = 'development') { + const url = `${this.api}/v1/skills/${skillId}/stages/${stage}` + + `/interactionModel/locales/${locale}` + const method = 'PUT' + const payload = { interactionModel } + return this._callApi({ url, method, payload }) + } + + async getVendors () { + const url = `${this.api}/v1/vendors` + return this._callApi({ url }) + } +} diff --git a/src/channel_integrations/amazon_alexa/test/integration.js b/src/channel_integrations/amazon_alexa/test/integration.js new file mode 100644 index 0000000..5eaad11 --- /dev/null +++ b/src/channel_integrations/amazon_alexa/test/integration.js @@ -0,0 +1,149 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' + +const expect = chai.expect +const should = chai.should() + +const channelCreationParams = { + type: 'amazonalexa', + slug: 'my-awesome-channel', + invocationName: 'My Bot', + isActivated: true, +} + +describe('Amazon Alexa Channel', () => { + + const { createChannel, deleteChannel, + updateChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + const amazonAPI = 'https://api.amazon.com' + + beforeEach(async () => { + nock(amazonAPI).post('/auth/o2/token').reply(200, { + access_token: '1337', + refresh_token: '1337', + }) + }) + + describe('creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + expect(result.invocationName).to.equal(channelCreationParams.invocationName) + expect(result.oAuthTokens).to.have.all.keys('access_token', 'refresh_token') + expect(result.oAuthTokens.access_token).to.be.a('string') + expect(result.oAuthTokens.refresh_token).to.be.a('string') + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.invocationName = 'Your Bot' + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.invocationName).to.equal(newValues.invocationName) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + let channel + const body = { + version: '1.0', + session: { + new: false, + sessionId: 'amzn1.echo-api.session.a313142e-bb3a-4cc1-8165-88ca4e1cf3fa', + application: { + applicationId: 'amzn1.ask.skill.0ef4a28b-1e00-49a9-b0f3-b67ea3364835', + }, + user: { + // eslint-disable-next-line max-len + userId: 'amzn1.ask.account.AEB564HYZGUIB3AFFXONLUR4FHMGPT2OOLUHC5C3BOQZCGTI6RRW2IQBUWIX57TMYQ64PNNIWYSPVADY6GQUMBFOUHRI6L2H6O6ZDN2KSOGGIUJJKEWCW4L23I2FN3FWHS5OCUV6EQEH2OCRAIX75XPLXVOFNHUMFCHWYM3YWVA6E4JTMMLT7RKFP3QRTB3DU44MD54ARFWRQHQ', + }, + }, + request: { + type: 'IntentRequest', + requestId: 'amzn1.echo-api.request.6013fceb-b7f9-4947-b796-b32afeb00580', + timestamp: '2018-07-02T21:33:55Z', + locale: 'en-US', + intent: { + name: 'CATCH_ALL_INTENT', + confirmationStatus: 'NONE', + slots: { + CATCH_ALL_SLOT: { + name: 'CATCH_ALL_SLOT', + value: 'hallo', + resolutions: { + resolutionsPerAuthority: [ + { + // eslint-disable-next-line max-len + authority: 'amzn1.er-authority.echo-sdk.amzn1.ask.skill.0ef4a28b-1e00-49a9-b0f3-b67ea3364835.CATCH_ALL_SLOT_TYPE', + status: { + code: 'ER_SUCCESS_NO_MATCH', + }, + }, + ], + }, + confirmationStatus: 'NONE', + }, + }, + }, + }, + } + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful with valid parameters', async () => { + // Hard-coded for testing purposes + const signature = 'sha1=a3a1f35918b7b8c8d187e75dfa793a843e5c2b3c' + const headers = { 'x-hub-signature': signature } + const response = await sendMessageToWebhook(channel, body, headers) + expect(response.status).to.equal(200) + expect(response.body).to.have.all.keys('version', 'response', 'sessionAttributes', + 'userAgent') + expect(response.body.version).to.equal('1.0') + expect(response.body.response).to.have.all.keys('outputSpeech', 'shouldEndSession') + expect(response.body.response.outputSpeech).to.have.all.keys('type', 'ssml') + expect(response.body.response.outputSpeech.type).to.equal('SSML') + expect(response.body.response.outputSpeech.ssml).to.equal('my message') + }) + + it('should return 400 with invalid parameters', async () => { + try { + await sendMessageToWebhook(channel, { version: '1337' }) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + + }) +}) diff --git a/src/channel_integrations/callr/channel.js b/src/channel_integrations/callr/channel.js new file mode 100644 index 0000000..062064f --- /dev/null +++ b/src/channel_integrations/callr/channel.js @@ -0,0 +1,107 @@ +import _ from 'lodash' +import callr from 'callr' +import crypto from 'crypto' + +import { logger, BadRequestError, ForbiddenError, textFormatMessage } from '../../utils' +import AbstractChannelIntegration from '../abstract_channel_integration' + +export default class Callr extends AbstractChannelIntegration { + + validateChannelObject (channel) { + if (!channel.password) { + throw new BadRequestError('Parameter password is missing') + } else if (!channel.userName) { + throw new BadRequestError('Parameter userName is missing') + } + } + + async beforeChannelCreated (channel) { + const type = 'sms.mo' + const context = { hmac_secret: channel.password, hmac_algo: 'SHA256' } + const api = new callr.api(channel.userName, channel.password) + + try { + await new Promise((resolve, reject) => + (api.call('webhooks.subscribe', type, channel.webhook, context) + .success(async (res) => { + channel.webhookToken = res.hash + resolve() + }) + .error(reject))) + channel.isErrored = false + } catch (err) { + logger.error('[CallR] Error while setting webhook', err) + channel.isErrored = true + } + + return channel.save() + } + + async afterChannelUpdated (channel, oldChannel) { + await this.afterChannelDeleted(oldChannel) + await this.beforeChannelCreated(channel) + } + + async afterChannelDeleted (channel) { + try { + const api = new callr.api(channel.userName, channel.password) + await new Promise((resolve, reject) => (api.call('webhooks.unsubscribe', channel.webhookToken) + .success(resolve) + .error(reject))) + } catch (err) { + logger.error(`[CallR] Error while unsetting webhook: ${err}`) + } + } + + authenticateWebhookRequest (req, res, channel) { + const { password } = channel + const payload = JSON.stringify(req.body) + const webhookSig = req.headers['x-callr-hmacsignature'] + const hash = crypto.createHmac('SHA256', password).update(payload).digest('base64') + + if (hash !== webhookSig) { + throw new ForbiddenError() + } + } + + populateMessageContext (req) { + return { + chatId: _.get(req, 'body.data.from'), + senderId: _.get(req, 'body.data.to'), + } + } + + parseIncomingMessage (conversation, message) { + return { + attachment: { + type: 'text', + content: message.data.text, + }, + channelType: 'callr', + } + } + + formatOutgoingMessage (conversation, message) { + let body + try { + ({ body } = textFormatMessage(message)) + } catch (error) { + throw new BadRequestError('Message type is non-supported by Callr') + } + + return body + } + + sendMessage (conversation, message, opts) { + return new Promise(async (resolve, reject) => { + const { senderId } = opts + const { chatId, channel } = conversation + const api = new callr.api(channel.userName, channel.password) + + api.call('sms.send', senderId, chatId, message, null) + .success(resolve) + .error(reject) + }) + } + +} diff --git a/src/channel_integrations/callr/index.js b/src/channel_integrations/callr/index.js new file mode 100644 index 0000000..25943cd --- /dev/null +++ b/src/channel_integrations/callr/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['callr'] } diff --git a/src/channel_integrations/callr/test/integration.js b/src/channel_integrations/callr/test/integration.js new file mode 100644 index 0000000..6b5625a --- /dev/null +++ b/src/channel_integrations/callr/test/integration.js @@ -0,0 +1,109 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' + +const expect = chai.expect +const should = chai.should() + +const channelCreationParams = { + type: 'callr', + slug: 'my-awesome-channel', + password: 'password', + userName: 'user', +} + +describe('Callr channel', () => { + + const { createChannel, deleteChannel, + updateChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + + beforeEach(() => { + nock(callrAPI).post('/json-rpc/v1.1/').reply(200, { result: { hash: 'webhook-hash' } }) + }) + const callrAPI = 'https://api.callr.com' + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.password = 'newpassword' + nock(callrAPI).post('/json-rpc/v1.1/').times(2).reply(200, { result: { } }) + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.password).to.equal(newValues.password) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful with valid token', async () => { + const message = { data: { from: 'sender', to: 'receiver', text: 'a message' } } + const headers = { 'x-callr-hmacsignature': 'sHMMGdzGhfW2urhzjUwY578gZct/lVFlAP3qrHTaUig=' } + const outgoingMessageCall = nock(callrAPI).post('/json-rpc/v1.1/') + .reply(200, { result: { message: 'message' } }) + const response = await sendMessageToWebhook(channel, message, headers) + expect(response.status).to.equal(200) + expect(outgoingMessageCall.isDone()).to.be.true + }) + + it('should return 401 with invalid token', async () => { + try { + const headers = { 'x-callr-hmacsignature': 'invalid' } + await sendMessageToWebhook(channel, {}, headers) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + it('should return 401 without a token', async () => { + try { + await sendMessageToWebhook(channel, {}) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + }) +}) diff --git a/src/services/CiscoSpark.service.js b/src/channel_integrations/cisco_spark/channel.js similarity index 69% rename from src/services/CiscoSpark.service.js rename to src/channel_integrations/cisco_spark/channel.js index ebb1389..5e41681 100644 --- a/src/services/CiscoSpark.service.js +++ b/src/channel_integrations/cisco_spark/channel.js @@ -1,36 +1,19 @@ import _ from 'lodash' import SparkClient from 'node-sparky' -import { Logger, arrayfy } from '../utils' -import { BadRequestError, StopPipeline } from '../utils/errors' -import Template from './Template.service' - -/* - * checkParamsValidity: ok - * onChannelCreate: ok - * onChannelUpdate: ok - * onChannelDelete: ok - * onWebhookChecking: default - * checkSecurity: default - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - -export default class CiscoSpark extends Template { - - static checkParamsValidity (channel) { +import { logger, arrayfy } from '../../utils' +import { BadRequestError, StopPipeline } from '../../utils/errors' +import AbstractChannelIntegration from '../abstract_channel_integration' + +export default class CiscoSpark extends AbstractChannelIntegration { + + validateChannelObject (channel) { if (!channel.token) { throw new BadRequestError('Parameter token is missing') } } - static async onChannelCreate (channel) { + async beforeChannelCreated (channel) { try { const spark = new SparkClient({ token: channel.token, webhookUrl: channel.webhook }) const webhook = { @@ -48,19 +31,19 @@ export default class CiscoSpark extends Template { channel.userName = me.id channel.isErrored = false } catch (err) { - Logger.info('[Cisco] Error while setting the webhook') + logger.error(`[Cisco] Error while setting the webhook: ${err}`) channel.isErrored = true } return channel.save() } - static async onChannelUpdate (channel, oldChannel) { - await CiscoSpark.onChannelDelete(oldChannel) - await CiscoSpark.onChannelCreate(channel) + async afterChannelUpdated (channel, oldChannel) { + await this.afterChannelDeleted(oldChannel) + await this.beforeChannelCreated(channel) } - static async onChannelDelete (channel) { + async afterChannelDeleted (channel) { try { const spark = new SparkClient({ token: channel.token }) const webhooks = await spark.webhooksGet() @@ -69,18 +52,18 @@ export default class CiscoSpark extends Template { if (!webhook) { return } await spark.webhookRemove(webhook.id) } catch (err) { - Logger.info('[Cisco] Error while unsetting the webhook') + logger.error(`[Cisco] Error while unsetting the webhook: ${err}`) } } - static extractOptions (req) { + populateMessageContext (req) { return { chatId: _.get(req, 'body.data.roomId'), senderId: _.get(req, 'body.data.personId'), } } - static async parseChannelMessage (conversation, message, opts) { + async parseIncomingMessage (conversation, message) { const spark = new SparkClient({ token: conversation.channel.token }) message = await spark.messageGet(message.data.id) // decrypt the message @@ -88,14 +71,10 @@ export default class CiscoSpark extends Template { throw new StopPipeline() } - return [ - conversation, - { attachment: { type: 'text', content: message.text } }, - { ...opts, mentioned: true }, - ] + return { attachment: { type: 'text', content: message.text } } } - static formatMessage (conversation, message) { + formatOutgoingMessage (conversation, message) { const { type, content } = _.get(message, 'attachment', {}) switch (type) { @@ -111,6 +90,7 @@ export default class CiscoSpark extends Template { markdown: _.reduce(payload, (acc, str) => `${acc}\n\n${str}`, ''), } } + case 'buttons': case 'quickReplies': return { markdown: `**${_.get(content, 'title', '')}**\n\n` @@ -131,12 +111,14 @@ export default class CiscoSpark extends Template { .concat(`*${_.get(card, 'subtitle', '')}*\n\n`) .concat(_.get(card, 'buttons', []).map(b => `- ${b.title}`).join('\n\n')), })) + case 'custom': + return content default: throw new BadRequestError('Message type non-supported by CiscoSpark') } } - static async sendMessage (conversation, messages, opts) { + async sendMessage (conversation, messages, opts) { if (conversation.channel.userName !== opts.senderId) { const spark = new SparkClient({ token: conversation.channel.token }) diff --git a/src/channel_integrations/cisco_spark/index.js b/src/channel_integrations/cisco_spark/index.js new file mode 100644 index 0000000..483441e --- /dev/null +++ b/src/channel_integrations/cisco_spark/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['ciscospark'] } diff --git a/src/channel_integrations/cisco_spark/test/integration.js b/src/channel_integrations/cisco_spark/test/integration.js new file mode 100644 index 0000000..b0bd81f --- /dev/null +++ b/src/channel_integrations/cisco_spark/test/integration.js @@ -0,0 +1,219 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' + +/* eslint max-nested-callbacks: 0 */ // --> OFF + +const expect = chai.expect +const should = chai.should() + +const channelCreationParams = { + type: 'ciscospark', + slug: 'my-awesome-channel', + token: 'token', +} + +describe('Cisco Spark channel', () => { + + const { createChannel, updateChannel, + deleteChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + const sparkAPI = 'https://api.ciscospark.com' + + beforeEach(() => { + nock(sparkAPI).get('/v1/people/me').reply(200, {}) + nock(sparkAPI).post('/v1/webhooks').reply(200, {}) + }) + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.token = 'newtoken' + nock(sparkAPI).get('/v1/people/me').reply(200, {}) + nock(sparkAPI).get('/v1/webhooks').query(true).reply(200, {}) + nock(sparkAPI).post('/v1/webhooks').reply(200, {}) + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.token).to.equal(newValues.token) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + nock(sparkAPI).get('/v1/webhooks').query(true).reply(200, {}) + + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful with valid parameters', async () => { + nock(sparkAPI).get('/v1/messages/message-id').query(true).reply( + 200, { personId: 123, text: 'text' }) + const outgoingMessageCall = nock(sparkAPI).post('/v1/messages').reply(200, {}) + const response = await sendMessageToWebhook(channel, { + data: { roomId: 'room-id', personId: 'person-id', id: 'message-id' }, + }) + expect(response.status).to.equal(200) + // Assert that response was sent to Cisco API + expect(outgoingMessageCall.isDone()).to.be.true + }) + + it('should not send a response for an empty incoming message', async () => { + nock(sparkAPI).get('/v1/messages/message-id').query(true).reply( + 200, { personId: 123, text: '' }) + const outgoingMessageCall = nock(sparkAPI).post('/v1/messages').reply(200, {}) + const response = await sendMessageToWebhook(channel, { + data: { roomId: 'room-id', personId: 'person-id', id: 'message-id' }, + }) + expect(response.status).to.equal(200) + // Assert that response was sent to Cisco API + expect(outgoingMessageCall.isDone()).to.be.false + }) + + describe('should be successful', () => { + + beforeEach(async () => { + nock(sparkAPI).get('/v1/messages/message-id').query(true).reply( + 200, { personId: 123, text: 'text' }) + nock(sparkAPI).post('/v1/messages').reply(200, {}) + }) + + const body = { + data: { roomId: 'room-id', personId: 'person-id', id: 'message-id' }, + } + const headers = {} + + it('in list format', async () => { + const buttons = [ + { type: 'account_link', title: 'button title', value: 'https://link.com' }, + { type: 'web_url', title: 'button title', value: 'https://link.com' }, + { type: 'phone_number', title: 'button title', value: '0123554' }] + const listElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons, + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'list', + content: { elements: [listElement], buttons }, + }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in card format', async () => { + const cardElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'card', content: cardElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in carousel format', async () => { + const carouselElement = { + title: 'title', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'carousel', content: [carouselElement] }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in quickReplies format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'quickReplies', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in video format', async () => { + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'video', content: 'https://link.com' }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in picture format', async () => { + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'picture', content: 'https://link.com' }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in buttons format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'buttons', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + }) + + }) +}) diff --git a/src/channel_integrations/facebook/channel.js b/src/channel_integrations/facebook/channel.js new file mode 100644 index 0000000..64af761 --- /dev/null +++ b/src/channel_integrations/facebook/channel.js @@ -0,0 +1,447 @@ +/* eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ +import _ from 'lodash' +import config from '../../../config' +import AbstractChannelIntegration from '../abstract_channel_integration' +import { + BadRequestError, + ForbiddenError, + getWebhookToken, + logger, + StopPipeline, +} from '../../utils' +import { + facebookGetAppWebhookToken, + facebookGetUserData, + facebookSendMessage, + facebookSendIsTyping, + facebookAddAppToPage, + facebookRemoveAppFromPage, + facebookComputeSignature, + facebookAddProfileProperties, + facebookDelProfileProperties, +} from './sdk' +import { facebookCodesMap } from './constants' +import { GetStartedButton, PersistentMenu } from '../../models' + +export default class Messenger extends AbstractChannelIntegration { + + validateChannelObject (channel) { + if (!channel.token) { + throw new BadRequestError('Parameter token is missing') + } else if (!channel.apiKey && !channel.serviceId) { + /* 1-click messenger integration + serviceId is a facebook page id + the apiKey (app secret) is not needed anymore since we use + our app with its corresponding secret + We check for both, waiting for frontend implementation */ + throw new BadRequestError('Parameter apiKey or serviceId is missing') + } + } + + async beforeChannelCreated (channel) { + /* 1-click messenger integration + This subscribes SAP Conversational AI facebook App to the user page + so gromit can receive the messages */ + const { serviceId: pageId, token: pageToken } = channel + if (pageId) { + await facebookAddAppToPage(pageId, pageToken) + } + } + + async afterChannelUpdated (channel, oldChannel) { + await this.afterChannelDeleted(oldChannel) + await this.beforeChannelCreated(channel) + } + + async afterChannelDeleted (channel) { + /* 1-click messenger integration + This removes SAP Conversational AI facebook App subscription to the user page + so gromit doesn't receive the messages anymore */ + const { serviceId: pageId, token: pageToken } = channel + if (pageId) { + await facebookRemoveAppFromPage(pageId, pageToken) + } + } + + buildWebhookUrl (channel) { + const { serviceId: pageId } = channel + if (pageId) { + /* 1-click messenger integration + add the shared messenger webhook endpoint to the channel */ + return `${config.base_url}/v1/webhook/service/messenger` + } + return super.buildWebhookUrl(channel) + } + + static webHookCheck (req, res, token) { + if (req.query['hub.mode'] === 'subscribe' && req.query['hub.verify_token'] === token) { + res.status(200).send(req.query['hub.challenge']) + } else { + throw new BadRequestError('Error while checking the webhook validity') + } + } + + onSharedWebhookChecking (req, res) { + Messenger.webHookCheck(req, res, facebookGetAppWebhookToken()) + } + + getIdPairsFromSharedWebhook (req) { + const serviceId = _.get(req, 'body.entry[0].messaging[0].recipient.id', null) + if (serviceId) { + return { serviceId } + } + } + + validateWebhookSubscriptionRequest (req, res, channel) { + Messenger.webHookCheck(req, res, getWebhookToken(channel._id, channel.slug)) + } + + async authenticateWebhookRequest (req, res, channel) { + const rawBody = _.get(req, 'rawBody') + const signature = _.get(req, ['headers', 'x-hub-signature']) + + const serviceId = _.get(channel, 'serviceId') + /* 1-click messenger integration + If there is a pageId, we are in new mode + the secret will be directly fetched by the signature function */ + const appSecret = serviceId ? null : _.get(channel, 'apiKey') + const calculated = facebookComputeSignature(rawBody, appSecret) + if (calculated !== signature) { + throw new ForbiddenError() + } + } + + async onWebhookCalled (req, res, channel) { + // send 200 OK early so that Facebook doesn't retry our web hook after 20s + // because sometimes we take more than that to produce and send a bot response back to Slack + res.status(200).json({ results: null, message: 'Message successfully received' }) + return channel + } + + finalizeWebhookRequest () { + // do nothing as 200 OK has already been sent + } + + populateMessageContext (req) { + const recipientId = _.get(req, 'body.entry[0].messaging[0].recipient.id') + const senderId = _.get(req, 'body.entry[0].messaging[0].sender.id') + + return { + chatId: `${recipientId}-${senderId}`, + senderId, + } + } + + onIsTyping (channel, context) { + const { senderId } = context + const { token: pageToken, apiKey: appSecret } = channel + return facebookSendIsTyping(senderId, pageToken, appSecret) + } + + parseIncomingMessage (conversation, message) { + const msg = {} + message = _.get(message, 'entry[0].messaging[0]') + const type = _.get(message, 'message.attachments[0].type') + const quickReply = _.get(message, 'message.quick_reply.payload') + + if (message.account_linking) { + const { status, authorization_code } = _.get(message, 'account_linking') + msg.attachment = { type: 'account_linking', status, content: authorization_code } + } else if (message.postback) { + const content = _.get(message, 'postback.payload') + msg.attachment = { type: 'payload', content } + } else if (message.referral) { + msg.attachment = { type: 'referral', content: message.referral } + } else if (!message.message || (message.message.is_echo && message.message.app_id)) { + throw new StopPipeline() + } else if (type) { + const attachment = _.get(message, 'message.attachments[0]') + // fallback type for an attachment is a link fetched by facebook and + // displayed in a special fashion (can be a video, an image...) + const content = _.get(attachment, (type === 'fallback') ? 'url' : 'payload.url') + msg.attachment = { + type: type === 'image' ? 'picture' : type, + content, + } + } else if (quickReply) { + msg.attachment = { type: 'text', content: quickReply, is_button_click: true } + } else { + const content = _.get(message, 'message.text') + msg.attachment = { type: 'text', content } + } + + if (message.message && message.message.is_echo) { + _.set(msg, 'attachment.isEcho', true) + if (!message.message.app_id) { + _.set(msg, 'attachment.isAdminMessage', true) + } + } + + return msg + } + + static formatButtons (buttons) { + return buttons.map(button => { + const { title } = button + const type = button.type || 'text' + const value = button.value || button.url + + if (['account_linking', 'account_link'].indexOf(type) !== -1) { + return { type: 'account_link', title, url: value } + } else if (type === 'web_url') { + return { type, title, url: value } + } else if (type === 'phonenumber') { + return { type: 'phone_number', title, payload: value } + } else if (['postback', 'phone_number', 'element_share'].indexOf(type) !== -1) { + return { type, title, payload: value } + } + return { type } + }) + } + + formatOutgoingMessage (conversation, message, opts) { + // https://developers.facebook.com/docs/messenger-platform/send-messages + const { type, content } = _.get(message, 'attachment') + const msg = { + recipient: { id: opts.senderId }, + message: {}, + messaging_type: 'RESPONSE', + } + + switch (type) { + case 'text': + _.set(msg, 'message', { text: content }) + break + case 'video': + case 'picture': + case 'audio': // Special case needed for StarWars ? + _.set(msg, 'message.attachment.type', type === 'picture' ? 'image' : type) + _.set(msg, 'message.attachment.payload.url', content) + break + case 'card': + // FIXME: FB messenger only supports up to 3 buttons on a card + // https://developers.facebook.com/docs/messenger-platform/reference/template/generic#elements + const { + title, + itemUrl: item_url, + imageUrl: image_url, + subtitle } = _.get(message, 'attachment.content', {}) + const buttons = Messenger.formatButtons(_.get(message, 'attachment.content.buttons', [])) + + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload.template_type', 'generic') + _.set(msg, + 'message.attachment.payload.elements', + [{ title, item_url, image_url, subtitle, buttons }]) + break + case 'quickReplies': + // FIXME: FB messenger only supports up to 11 quick reply buttons + // https://developers.facebook.com/docs/messenger-platform/reference/send-api/quick-replies + const text = _.get(message, 'attachment.content.title', '') + const quick_replies = _.get(message, 'attachment.content.buttons', []) + .map(b => ({ content_type: 'text', title: b.title, payload: b.value })) + + _.set(msg, 'message', { text, quick_replies }) + break + case 'list': { + const rawElements = _.get(message, 'attachment.content.elements', []) + const elements = rawElements.map(e => ({ + title: e.title, + image_url: e.imageUrl, + subtitle: e.subtitle, + buttons: e.buttons && Messenger.formatButtons(e.buttons), + })) + + // FB Messenger only supports lists with 2 - 4 elements + // workaround so this doesn't result in an error but we still are + // successful and not block following messages + // (FB would just retry the failed message over and over if status code != 200) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + if (elements.length < 2) { + // if list has only one element, send success instead of failing later + logger.error(`[Facebook Messenger] Channel ${conversation.channel.id} tried to send a list ` + + 'with less than 2 elements. List not sent.') + throw new StopPipeline() + } else if (elements.length > 4) { + // only take the first four elements of the list + elements.splice(4) + logger.error(`[Facebook Messenger] Channel ${conversation.channel.id} tried to send a list ` + + 'with more than 4 elements. Last elements omitted.') + } + + const payload = { template_type: 'list', elements } + + // In normal conditions, the first image must always have an image + if (rawElements.length > 0 && !('imageUrl' in rawElements[0])) { + payload.top_element_style = 'compact' + } + + const buttons = Messenger.formatButtons(_.get(message, 'attachment.content.buttons', [])) + if (buttons.length > 0) { + _.set(msg, 'message.attachment.payload.buttons', buttons) + } + + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload', payload) + break + } + case 'carousel': + case 'carouselle': + // FIXME: FB messenger only supports up to 10 carousel elements + // https://developers.facebook.com/docs/messenger-platform/reference/template/generic#payload + const elements = _.get(message, 'attachment.content', []) + .map(content => { + const { title, itemUrl: item_url, imageUrl: image_url, subtitle } = content + const buttons = Messenger.formatButtons(_.get(content, 'buttons', [])) + const element = { title, subtitle, item_url, image_url } + + if (buttons.length > 0) { + _.set(element, 'buttons', buttons) + } + + return element + }) + + if (elements.splice(10).length !== 0) { + logger.error(`[Facebook Messenger] Channel ${conversation.channel.id} tried to + send a carousel with more than 10 elements. Last elements omitted.`) + } + + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload.template_type', 'generic') + _.set(msg, 'message.attachment.payload.elements', elements) + break + case 'buttons': { + // FIXME: FB messenger only supports up to 3 buttons + // https://developers.facebook.com/docs/messenger-platform/reference/template/button#payload + const text = _.get(message, 'attachment.content.title', '') + const payload = { template_type: 'button', text } + + _.set(msg, 'message.attachment.type', 'template') + _.set(msg, 'message.attachment.payload', payload) + + const buttons = Messenger.formatButtons(_.get(message, 'attachment.content.buttons', [])) + if (buttons.length > 0) { + _.set(msg, 'message.attachment.payload.buttons', buttons) + } + break + } + case 'custom': + _.set(msg, 'message', content) + break + default: + throw new BadRequestError('Message type non-supported by Messenger') + } + + return msg + } + + async sendMessage (conversation, message) { + const { channel: { token: pageToken, apiKey: appSecret } } = conversation + await facebookSendMessage(message, pageToken, appSecret) + } + + /* + * Gromit methods + */ + + async populateParticipantData (participant, channel) { + const fields = 'first_name,last_name,profile_pic,locale,timezone,gender' + const { token: pageToken, apiKey: appSecret } = channel + const { id, ...participantData } + = await facebookGetUserData(participant.senderId, pageToken, fields, appSecret) + participant.data = participantData + participant.markModified('data') + return participant.save() + } + + parseParticipantDisplayName (participant) { + const informations = {} + + if (participant.data) { + const { first_name, last_name } = participant.data + informations.userName = `${first_name} ${last_name}` + } + + return informations + } + + formatItems (menu) { + menu.call_to_actions.map(item => { + if (item.type === 'Link') { + item.type = 'web_url' + item.url = item.payload + delete item.payload + } else if (item.type === 'nested') { + this.formatItems(item) + } + return item + }) + } + + formatPersistentMenu (menus) { + const persistent_menu = menus.reduce((formattedMenus, currentMenu) => { + // copying currentMenu.menu into currentFormattedMenu to not modify the currentMenu + const currentFormattedMenu = JSON.parse(JSON.stringify(currentMenu.menu)) + if (!facebookCodesMap[currentMenu.locale]) { + throw new BadRequestError('Language non-supported by Messenger') + } + if (currentMenu.default === true) { + currentFormattedMenu.locale = 'default' + } else { + currentFormattedMenu.locale = facebookCodesMap[currentMenu.locale] + } + currentFormattedMenu.composer_input_disabled = false + this.formatItems(currentFormattedMenu) + formattedMenus.push(currentFormattedMenu) + return formattedMenus + }, []) + return persistent_menu + } + + async setGetStartedButton (channel, value, connector = null) { + const pageToken = channel.token + const properties = { + get_started: { payload: value }, + persistent_menu: [], + } + if (connector) { + const menus = await PersistentMenu.find({ connector_id: connector._id }) + if (menus.length) { + properties.persistent_menu = this.formatPersistentMenu(menus) + } + } + await facebookAddProfileProperties(properties, pageToken) + } + + async deleteGetStartedButton (channel) { + const pageToken = channel.token + const property = ['get_started', 'persistent_menu'] + + await facebookDelProfileProperties(property, pageToken) + } + + /** + * format all the menus and send it to facebook + * Sets the locale to default or to the corresponding facebook code + * @param {channel} an instance of the channel model + * @param {menus} an array of PersistentMenu + * @returns undefined + */ + async setPersistentMenu (channel, menus) { + // setting persistent menu only if the channel has a get started button + if (!await GetStartedButton.findOne({ channel_id: channel._id })) { + return + } + const pageToken = channel.token + const property = { persistent_menu: [] } + property.persistent_menu = this.formatPersistentMenu(menus) + await facebookAddProfileProperties(property, pageToken) + } + + async deletePersistentMenu (channel) { + const pageToken = channel.token + const property = ['persistent_menu'] + await facebookDelProfileProperties(property, pageToken) + } +} diff --git a/src/channel_integrations/facebook/constants.js b/src/channel_integrations/facebook/constants.js new file mode 100644 index 0000000..1d14db5 --- /dev/null +++ b/src/channel_integrations/facebook/constants.js @@ -0,0 +1,46 @@ +export const facebookCodesMap = { + fr: 'fr_FR', + en: 'en_US', + es: 'es_ES', + ar: 'ar_AR', + ca: 'ca_ES', + da: 'da_DK', + de: 'de_DE', + fi: 'fi_FI', + hi: 'hi_IN', + it: 'it_IT', + ja: 'ja_JP', + ko: 'ko_KR', + no: 'nb_NO', + nl: 'nl_NL', + pl: 'pl_PL', + pt: 'pt_PT', + ru: 'ru_RU', + sv: 'sv_SE', + zh: 'zh_CN', + az: 'az_AZ', + be: 'be_BY', + bn: 'bn_IN', + cs: 'cs_CZ', + el: 'el_GR', + fa: 'fa_IR', + ha: 'ha_NG', + he: 'he_IL', + hu: 'hu_HU', + id: 'id_ID', + km: 'km_KH', + ms: 'ms_MY', + my: 'my_MM', + ne: 'ne_NP', + pa: 'pa_IN', + ro: 'ro_RO', + si: 'si_LK', + sr: 'sr_RS', + th: 'th_TH', + tl: 'tl_PH', + tr: 'tr_TR', + uk: 'uk_UA', + uz: 'uz_UZ', + vi: 'vi_VN', + ur: 'ur_PK', +} diff --git a/src/channel_integrations/facebook/controller.js b/src/channel_integrations/facebook/controller.js new file mode 100644 index 0000000..85c486f --- /dev/null +++ b/src/channel_integrations/facebook/controller.js @@ -0,0 +1,128 @@ +import { validateFacebookToken } from './middlewares' +import { renderOk, BadRequestError, ServiceError } from '../../utils' +import { + facebookGetAppToken, + facebookGetClientToken, + facebookGetExtendedClientToken, + facebookGetProfile, + facebookGetUserPages, + facebookGetPagesTokens, + facebookGetPagesPictures, +} from './sdk' + +const USER_PERM_ADMINISTER = 'ADMINISTER' +const USER_PERM_EDIT_PROFILE = 'EDIT_PROFILE' + +const checkClientToken = (clientToken, clientTokenType) => { + if (!clientToken || clientTokenType !== 'bearer') { + throw new ServiceError('Error while requesting facebook client token') + } +} + +export default class FacebookController { + static async getTokenFromCode (req, res) { + const { facebook_code: code, facebook_redirect: redirectUri } = req.query + + if (!code || !redirectUri) { + throw new BadRequestError('Missing facebook_code or facebook_redirect parameter') + } + + const appToken = await facebookGetAppToken() + + const { access_token: clientToken, token_type: clientTokenType, expires_in: clientTokenExpiry } + = await facebookGetClientToken(code, redirectUri, appToken) + + checkClientToken(clientToken, clientTokenType) + + const clientId = await validateFacebookToken(null, clientToken, appToken) + + return renderOk(res, { + results: { + facebook_token: clientToken, + facebook_user: clientId, + facebook_expiry: clientTokenExpiry, + }, + message: 'Facebook token successfully received', + }) + } + + static async refreshToken (req, res) { + const { clientId, clientToken, appToken } = req + + const { + access_token: newClientToken, + token_type: newClientTokenType, + expires_in: newClientTokenExpiry, + } + = await facebookGetExtendedClientToken(clientToken) + + checkClientToken(newClientToken, newClientTokenType) + + await validateFacebookToken(clientId, newClientToken, appToken) + + return renderOk(res, { + results: { + facebook_token: newClientToken, + facebook_user: clientId, + facebook_expiry: newClientTokenExpiry, + }, + message: 'Facebook token successfully refreshed', + }) + } + + static async getProfile (req, res) { + const { clientToken } = req + + const fields = 'name,picture' + const { name, picture: { data: { url: picture } } } + = await facebookGetProfile(clientToken, fields) + + return renderOk(res, { + results: { + name, + picture, + }, + message: 'Facebook profile successfully received', + }) + } + + static async getPages (req, res) { + const { clientToken, appToken } = req + + const pages = await facebookGetUserPages(clientToken) + const userAdministeredPages = pages + .filter( + page => + page.perms.includes(USER_PERM_ADMINISTER) + || page.perms.includes(USER_PERM_EDIT_PROFILE)) + .reduce((acc, page) => { + const { name, id } = page + return { + ...acc, + [id]: { name }, + } + }, {}) + + const pageIds = Object.keys(userAdministeredPages) + const [pagesPictures, pagesTokens] = await Promise.all([ + facebookGetPagesPictures(pageIds, appToken), + facebookGetPagesTokens(pageIds, clientToken), + ]) + + pagesTokens.forEach((tokenArray) => { + const pageId = tokenArray[0] + const pageToken = tokenArray[1] + userAdministeredPages[pageId].token = pageToken + }) + + pagesPictures.forEach((picture, index) => { + const picturePageId = pageIds[index] + userAdministeredPages[picturePageId].picture = picture.url + }) + + return renderOk(res, { + results: userAdministeredPages, + message: 'List of facebook pages successfully found', + }) + } +} diff --git a/src/channel_integrations/facebook/index.js b/src/channel_integrations/facebook/index.js new file mode 100644 index 0000000..dcc7219 --- /dev/null +++ b/src/channel_integrations/facebook/index.js @@ -0,0 +1,4 @@ +import channel from './channel' +import routes from './routes' + +module.exports = { channel, routes, identifiers: ['messenger'] } diff --git a/src/channel_integrations/facebook/middlewares.js b/src/channel_integrations/facebook/middlewares.js new file mode 100644 index 0000000..d5ab7f8 --- /dev/null +++ b/src/channel_integrations/facebook/middlewares.js @@ -0,0 +1,53 @@ +import { + facebookGetAppId, + facebookGetAppToken, + facebookGetClientTokenInformation, +} from './sdk' +import { BadRequestError, ForbiddenError } from '../../utils' + +const CLIENT_TOKEN_PERMS = [ + 'public_profile', + 'email', + 'manage_pages', + 'pages_messaging', + 'pages_messaging_subscriptions', +] + +export const validateFacebookParams = async (req) => { + const { facebook_token: clientToken, facebook_user: clientId } = req.query + if (!clientToken || !clientId) { + throw new BadRequestError('Missing facebook_token, facebook_expiry or facebook_user parameter') + } + + const appToken = await facebookGetAppToken() + + await validateFacebookToken(clientId, clientToken, appToken) + + req.clientId = clientId + req.clientToken = clientToken + req.appToken = appToken +} + +export const validateFacebookToken = async (clientId, clientToken, appToken) => { + const { + data: { + app_id: clientTokenAppId, + type: clientTokenType, + is_valid: clientTokenIsValid, + user_id: clientTokenUserId, + scopes: clientTokenScopes, + }, + } = await facebookGetClientTokenInformation(clientToken, appToken) + + if ( + !clientTokenIsValid + || clientTokenType !== 'USER' + || (clientId && clientTokenUserId !== clientId) + || clientTokenAppId !== facebookGetAppId() + || !CLIENT_TOKEN_PERMS.every((perm) => clientTokenScopes.includes(perm)) + ) { + throw new ForbiddenError() + } + + return clientTokenUserId +} diff --git a/src/channel_integrations/facebook/routes.js b/src/channel_integrations/facebook/routes.js new file mode 100644 index 0000000..df242b5 --- /dev/null +++ b/src/channel_integrations/facebook/routes.js @@ -0,0 +1,33 @@ +import { validateFacebookParams } from './middlewares' +import controller from './controller' + +export default [ + { + method: 'GET', + path: ['/facebook/token'], + validators: [], + authenticators: [], + handler: controller.getTokenFromCode, + }, + { + method: 'GET', + path: ['/facebook/refresh_token'], + validators: [], + authenticators: [validateFacebookParams], + handler: controller.refreshToken, + }, + { + method: 'GET', + path: ['/facebook/profile'], + validators: [], + authenticators: [validateFacebookParams], + handler: controller.getProfile, + }, + { + method: 'GET', + path: ['/facebook/pages'], + validators: [], + authenticators: [validateFacebookParams], + handler: controller.getPages, + }, +] diff --git a/src/channel_integrations/facebook/sdk.js b/src/channel_integrations/facebook/sdk.js new file mode 100644 index 0000000..b6cf816 --- /dev/null +++ b/src/channel_integrations/facebook/sdk.js @@ -0,0 +1,240 @@ +import _ from 'lodash' +import { createHmac } from 'crypto' +import Promise from 'bluebird' +import Graph from 'fbgraph' +import config from '../../../config' +import { ServiceError } from '../../utils' + +Graph.setVersion('2.11') +Promise.promisifyAll(Graph) + +export const facebookGetAppId = () => config.facebook_app_id +const getAppSecret = () => config.facebook_app_secret + +const getOAuthToken = async (appId, appSecret, params) => { + const response = await Graph.getAsync('/oauth/access_token', { + client_id: appId, + client_secret: appSecret, + ...params, + }) + return response +} + +export const facebookGetAppToken = async () => { + const appId = facebookGetAppId() + const appSecret = getAppSecret() + const response = await getOAuthToken(appId, appSecret, { grant_type: 'client_credentials' }) + + const { access_token: appToken, token_type: appTokenType } = response + if (!appToken || appTokenType !== 'bearer') { + throw new ServiceError('Error while requesting facebook application token') + } + return appToken +} + +export const facebookGetClientTokenInformation = async (clientToken, appToken) => { + const response = await Graph.getAsync('/debug_token', { + input_token: clientToken, + access_token: appToken, + }) + return response +} + +const getAuthSecureParams = (token, appSecret) => { + const hmac = createHmac('sha256', appSecret) + hmac.update(token) + return { + access_token: token, + appsecret_proof: hmac.digest('hex'), + } +} + +export const facebookGetClientToken = async (code, redirectUri, appToken) => { + const appId = facebookGetAppId() + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(appToken, appSecret) + + const response = await getOAuthToken(appId, appSecret, { + redirect_uri: redirectUri, + code, + ...authSecureParams, + }) + + return response +} + +export const facebookGetExtendedClientToken = async (clientToken) => { + const appId = facebookGetAppId() + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(clientToken, appSecret) + + const response = await getOAuthToken(appId, appSecret, { + grant_type: 'fb_exchange_token', + fb_exchange_token: clientToken, + ...authSecureParams, + }) + + return response +} + +export const facebookGetProfile = async (clientToken, fields) => { + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(clientToken, appSecret) + + const data = await Graph.getAsync('/me', { + fields, + ...authSecureParams, + }) + return data +} + +export const facebookGetUserData = async (userId, pageToken, fields, appSecret) => { + const properAppSecret = appSecret || getAppSecret() + const authSecureParams = getAuthSecureParams(pageToken, properAppSecret) + + const data = await Graph.getAsync(`/${userId}`, { + fields, + ...authSecureParams, + }) + return data +} + +const messageMethod = async (messageData, pageToken, appSecret) => { + const properAppSecret = appSecret || getAppSecret() + const authSecureParams = getAuthSecureParams(pageToken, properAppSecret) + + const data = await Graph.postAsync('/me/messages', { + ...authSecureParams, + ...messageData, + }) + return data +} + +export const facebookSendMessage = async (message, pageToken, appSecret) => { + await messageMethod(message, pageToken, appSecret) +} + +export const facebookSendIsTyping = async (recipientId, pageToken, appSecret) => { + const data = { + recipient: { id: recipientId }, + sender_action: 'typing_on', + } + await messageMethod(data, pageToken, appSecret) +} + +const getUserPagesRecursive = async (params, after = undefined) => { + const requestParams = _.omitBy({ + ...params, + after, + }, _.isNil) + + const { data, paging: { next, cursors: { after: afterCursor } } } + = await Graph.getAsync('/me/accounts', requestParams) + + if (next) { + return [ + ...data, + ...getUserPagesRecursive(params, afterCursor), + ] + } + return data +} + +export const facebookGetUserPages = async (clientToken) => { + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(clientToken, appSecret) + + const pages = await getUserPagesRecursive(authSecureParams) + return pages +} + +export const facebookGetPagesTokens = async (pageIds, clientToken) => { + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(clientToken, appSecret) + + const batchRequests = pageIds.reduce((acc, pageId) => { + return [ + ...acc, + { + method: 'GET', + relative_url: `${pageId}?fields=access_token`, + }, + ] + }, []) + + const response = await Graph.batchAsync(batchRequests, authSecureParams) + const filteredResponse = response.map(r => { + const body = r.body ? JSON.parse(r.body) : null + if (!body) { + throw new ServiceError('Error while getting facebook pages tokens') + } + return [body.id, body.access_token] + }) + return filteredResponse +} + +export const facebookGetPagesPictures = async (pageIds, appToken) => { + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(appToken, appSecret) + + const batchRequests = pageIds.reduce((acc, pageId) => { + return [ + ...acc, + { + method: 'GET', + relative_url: `${pageId}/picture?redirect=0`, + }, + ] + }, []) + + const response = await Graph.batchAsync(batchRequests, authSecureParams) + const filteredResponse = response.map(r => { + const body = r.body ? JSON.parse(r.body) : null + if (!body) { + throw new ServiceError('Error while getting facebook pages pictures') + } + return body.data + }) + return filteredResponse +} + +export const facebookAddAppToPage = async (pageId, pageToken) => { + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(pageToken, appSecret) + + const { success } = await Graph.postAsync(`/${pageId}/subscribed_apps`, { + ...authSecureParams, + }) + return success +} + +export const facebookRemoveAppFromPage = async (pageId, pageToken) => { + const appSecret = getAppSecret() + const authSecureParams = getAuthSecureParams(pageToken, appSecret) + + const { success } = await Graph.delAsync(`/${pageId}/subscribed_apps`, { + ...authSecureParams, + }) + return success +} + +export const facebookGetAppWebhookToken = () => config.facebook_app_webhook_token + +export const facebookComputeSignature = (rawBody, appSecret) => { + const properAppSecret = appSecret || getAppSecret() + + const hmac = createHmac('sha1', properAppSecret) + hmac.update(rawBody, 'utf-8') + const digest = hmac.digest('hex') + return `sha1=${digest}` +} + +export const facebookAddProfileProperties = async (properties, pageToken) => { + await Graph.postAsync(`/me/messenger_profile?access_token=${pageToken}`, { ...properties }) +} + +export const facebookDelProfileProperties = async (properties, pageToken) => { + const fields = { fields: properties } + await Graph.delAsync(`/me/messenger_profile?access_token=${pageToken}`, { ...fields }) + +} diff --git a/src/channel_integrations/facebook/test/integration.js b/src/channel_integrations/facebook/test/integration.js new file mode 100644 index 0000000..aa03ae7 --- /dev/null +++ b/src/channel_integrations/facebook/test/integration.js @@ -0,0 +1,504 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' +import _ from 'lodash' +import qs from 'qs' + +const expect = chai.expect +const should = chai.should() + +/* eslint max-nested-callbacks: 0 */ // --> OFF + +const channelCreationParams = { + type: 'messenger', + slug: 'my-awesome-channel', + apiKey: 'api-key', + serviceId: 'service-id', + token: 'token', +} + +describe('Facebook Messenger Channel', () => { + + const { createChannel, deleteChannel, + updateChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + const facebookAPI = 'https://graph.facebook.com:443' + + beforeEach(async () => { + nock(facebookAPI).post('/v2.11/service-id/subscribed_apps').query(true).reply(200, {}) + }) + + describe('creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.token = 'newtoken' + nock(facebookAPI).post('/v2.11/service-id/subscribed_apps') + .times(2).query(true).reply(200, {}) + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.token).to.equal(newValues.token) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + const senderId = 456 + const body = { + entry: [{ + messaging: [{ + message: { + text: 'my message', + }, + text: 'my message', + recipient: { id: channelCreationParams.serviceId }, + sender: { id: senderId }, + }], + }], + } + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + // Hard-coded for testing purposes + const signature = 'sha1=a3a1f35918b7b8c8d187e75dfa793a843e5c2b3c' + const headers = { 'x-hub-signature': signature } + + it('should be successful with valid parameters and valid signature', async () => { + nock(facebookAPI).get('/v2.11/456').query(true).reply(200, { + id: senderId, + }) + const outgoingMessageCall = nock(facebookAPI).post('/v2.11/me/messages') + .times(2) + .reply(200, {}) + const outgoingMessageCallPromise = new Promise((resolve) => { + outgoingMessageCall.on('replied', () => { + if (outgoingMessageCall.isDone()) { + resolve() + } + }) + }) + const response = await sendMessageToWebhook(channel, body, headers) + expect(response.status).to.equal(200) + await outgoingMessageCallPromise + }) + + it('should return 401 with valid parameters, but invalid signature', async () => { + try { + const signature = 'sha1=invalid-signature' + const headers = { 'x-hub-signature': signature } + await sendMessageToWebhook(channel, body, headers) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + function mockIsTypingCall () { + const isTypingCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + recipient: { id: `${senderId}` }, + sender_action: 'typing_on', + } + return _.isEqual(expectedQuery, qs.parse(requestBody)) + }).query(true).reply(200, {}) + return isTypingCall + } + + describe('should be successful', () => { + + beforeEach(async () => { + nock(facebookAPI).get(`/v2.11/${senderId}`).query(true).reply(200, { + id: senderId, + }) + }) + + describe('in list format', () => { + // Facebook Messenger supports lists with 2 - 4 elements (2018-08-02) + + it('with too few elements') + + it('with supported length', async () => { + const listElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [ + { type: 'account_link', title: 'button title', value: 'https://link.com' }, + { type: 'web_url', title: 'button title', value: 'https://link.com' }, + { type: 'phone_number', title: 'button title', value: '0123554' }], + } + const isTypingCall = mockIsTypingCall() + const isTypingCallPromise = new Promise((resolve) => { + isTypingCall.on('replied', () => { + if (isTypingCall.isDone()) { + resolve() + } + }) + }) + const messageCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const parsed = qs.parse(requestBody) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + messaging_type: 'RESPONSE', + recipient: { id: `${senderId}` }, + message: { + attachment: { + type: 'template', + payload: { + template_type: 'list', + elements: Array(2).fill({ + title: listElement.title, + subtitle: listElement.subtitle, + image_url: listElement.imageUrl, + buttons: { + // 'qs' module has trouble parsing FB's query body + '[0][title]': listElement.buttons[0].title, + '[0][type]': listElement.buttons[0].type, + '[0][url]': listElement.buttons[0].value, + '[1][title]': listElement.buttons[1].title, + '[1][type]': listElement.buttons[1].type, + '[1][url]': listElement.buttons[1].value, + '[2][title]': listElement.buttons[2].title, + '[2][type]': listElement.buttons[2].type, + '[2][payload]': listElement.buttons[2].value, + }, + }), + }, + }, + }, + } + return _.isEqual(expectedQuery, parsed) + }).query(true).reply(200, {}) + const messageCallPromise = new Promise((resolve) => { + messageCall.on('replied', () => { + if (messageCall.isDone()) { + resolve() + } + }) + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'list', + content: { elements: Array(2).fill(listElement) }, + }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + return Promise.all([isTypingCallPromise, messageCallPromise]) + }) + + it('with too many elements') + }) + + it('in card format', async () => { + const cardElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const isTypingCall = mockIsTypingCall() + const isTypingCallPromise = new Promise((resolve) => { + isTypingCall.on('replied', () => { + if (isTypingCall.isDone()) { + resolve() + } + }) + }) + const messageCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const parsed = qs.parse(requestBody) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + messaging_type: 'RESPONSE', + recipient: { id: `${senderId}` }, + message: { + attachment: { + type: 'template', + payload: { + template_type: 'generic', + elements: [{ + title: cardElement.title, + subtitle: cardElement.subtitle, + image_url: cardElement.imageUrl, + buttons: { + // '[0][title]': cardElement.buttons[0].title, + '[0][type]': 'text', + }, + }], + }, + }, + }, + } + return _.isEqual(expectedQuery, parsed) + }).query(true).reply(200, {}) + const messageCallPromise = new Promise((resolve) => { + messageCall.on('replied', () => { + if (messageCall.isDone()) { + resolve() + } + }) + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'card', content: cardElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + return Promise.all([isTypingCallPromise, messageCallPromise]) + }) + + it('in carousel format', async () => { + const carouselElement = { + title: 'title', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const isTypingCall = mockIsTypingCall() + const isTypingCallPromise = new Promise((resolve) => { + isTypingCall.on('replied', () => { + if (isTypingCall.isDone()) { + resolve() + } + }) + }) + const messageCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const parsed = qs.parse(requestBody) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + messaging_type: 'RESPONSE', + recipient: { id: `${senderId}` }, + message: { + attachment: { + type: 'template', + payload: { + template_type: 'generic', + elements: [{ + title: carouselElement.title, + image_url: carouselElement.imageUrl, + buttons: { + // '[0][title]': carouselElement.buttons[0].title, + '[0][type]': 'text', + }, + }], + }, + }, + }, + } + return _.isEqual(expectedQuery, parsed) + }).query(true).reply(200, {}) + const messageCallPromise = new Promise((resolve) => { + messageCall.on('replied', () => { + if (messageCall.isDone()) { + resolve() + } + }) + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'carousel', content: [carouselElement] }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + return Promise.all([isTypingCallPromise, messageCallPromise]) + }) + + it('in quickReplies format', async () => { + const quickReplies = { + title: 'title', + buttons: [ + { type: '', title: 'button title', value: 'abc' }, + { type: 'postback', title: '1991 - 1996', value: '1991 - 1996' }, + ], + } + const isTypingCall = mockIsTypingCall() + const isTypingCallPromise = new Promise((resolve) => { + isTypingCall.on('replied', () => { + if (isTypingCall.isDone()) { + resolve() + } + }) + }) + const messageCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const parsed = qs.parse(requestBody) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + messaging_type: 'RESPONSE', + recipient: { id: `${senderId}` }, + message: { + text: quickReplies.title, + quick_replies: [ + { + content_type: 'text', + title: quickReplies.buttons[0].title, + payload: quickReplies.buttons[0].value, + }, + { + content_type: 'text', + title: quickReplies.buttons[1].title, + payload: quickReplies.buttons[1].value, + }, + ], + }, + } + return _.isEqual(expectedQuery, parsed) + }).query(true).reply(200, {}) + const messageCallPromise = new Promise((resolve) => { + messageCall.on('replied', () => { + if (messageCall.isDone()) { + resolve() + } + }) + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'quickReplies', content: quickReplies }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + return Promise.all([isTypingCallPromise, messageCallPromise]) + }) + + it('in video format', async () => { + const videoLink = 'https://link.com' + const isTypingCall = mockIsTypingCall() + const isTypingCallPromise = new Promise((resolve) => { + isTypingCall.on('replied', () => { + if (isTypingCall.isDone()) { + resolve() + } + }) + }) + const messageCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const parsed = qs.parse(requestBody) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + messaging_type: 'RESPONSE', + recipient: { id: `${senderId}` }, + message: { + attachment: { + type: 'video', + payload: { url: videoLink }, + }, + }, + } + return _.isEqual(expectedQuery, parsed) + }).query(true).reply(200, {}) + const messageCallPromise = new Promise((resolve) => { + messageCall.on('replied', () => { + if (messageCall.isDone()) { + resolve() + } + }) + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'video', content: videoLink }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + return Promise.all([isTypingCallPromise, messageCallPromise]) + }) + + it('in buttons format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: 'text', title: 'button title', value: 'abc' }], + } + const isTypingCall = mockIsTypingCall() + const isTypingCallPromise = new Promise((resolve) => { + isTypingCall.on('replied', () => { + if (isTypingCall.isDone()) { + resolve() + } + }) + }) + const messageCall = nock(facebookAPI).post('/v2.11/me/messages', (requestBody) => { + const parsed = qs.parse(requestBody) + // https://developers.facebook.com/docs/messenger-platform/send-messages/template/list + const expectedQuery = { + access_token: channelCreationParams.token, + appsecret_proof: 'a0db4e58b0b2a5434a6f95e1ed797f311e42feee3c71f7c6f057a649ef1a8291', + messaging_type: 'RESPONSE', + recipient: { id: `${senderId}` }, + message: { + attachment: { + type: 'template', + payload: { + template_type: 'button', + text: buttonsElement.title, + buttons: [{ + type: 'text', + }], + }, + }, + }, + } + return _.isEqual(expectedQuery, parsed) + }).query(true).reply(200, {}) + const messageCallPromise = new Promise((resolve) => { + messageCall.on('replied', () => { + if (messageCall.isDone()) { + resolve() + } + }) + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'buttons', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + return Promise.all([isTypingCallPromise, messageCallPromise]) + }) + }) + + }) +}) diff --git a/src/channel_integrations/index.js b/src/channel_integrations/index.js new file mode 100644 index 0000000..71f2864 --- /dev/null +++ b/src/channel_integrations/index.js @@ -0,0 +1,77 @@ +import AmazonAlexa from './amazon_alexa/channel' +import Callr from './callr/channel' +import CiscoSpark from './cisco_spark/channel' +import Kik from './kik/channel' +import Line from './line/channel' +import Messenger from './facebook/channel' +import Microsoft from './microsoft/channel' +import Slack from './slack/channel' +import SlackApp from './slack_app/channel' +import Telegram from './telegram/channel' +import Twilio from './twilio/channel' +import Twitter from './twitter/channel' +import Webchat from './webchat/channel' + +export default { + AmazonAlexa, + Callr, + CiscoSpark, + Kik, + Line, + Messenger, + Microsoft, + Slack, + SlackApp, + Telegram, + Twilio, + Twitter, + Webchat, +} +/** + * Lists the names of all available channel integration modules + * @type {string[]} + */ +export const MODULES = [ + 'amazon_alexa', + 'callr', + 'cisco_spark', + 'facebook', + 'kik', + 'line', + 'microsoft', + 'slack', + 'slack_app', + 'telegram', + 'twilio', + 'twitter', + 'webchat', +] + +function getChannelModules () { + return MODULES.map(moduleName => require(`./${moduleName}`)) +} + +/** + * Collects all custom routes defined in channel integrations. + * @return {Route[]} Array of route objects + */ +export function getChannelIntegrationRoutes () { + const routeLists = getChannelModules() + .map(module => module.routes || []) + .filter(routes => routes.length) + return [].concat(...routeLists) +} + +/** + * Retrieves an instance of a channel integration for a given identifier. + * @param {string} identifier One of the channel integration identifier + * @return {AbstractChannelIntegration} An instance of the channel integration matching + * the given identifier + */ +export function getChannelIntegrationByIdentifier (identifier) { + const module = getChannelModules().find(module => module.identifiers.includes(identifier)) + if (!module) { + return undefined + } + return new module.channel() +} diff --git a/src/services/Kik.service.js b/src/channel_integrations/kik/channel.js similarity index 56% rename from src/services/Kik.service.js rename to src/channel_integrations/kik/channel.js index 8af1358..6e5d6c7 100644 --- a/src/services/Kik.service.js +++ b/src/channel_integrations/kik/channel.js @@ -1,32 +1,15 @@ import _ from 'lodash' import request from 'superagent' -import { Logger, arrayfy } from '../utils' -import Template from './Template.service' -import { BadRequestError, ForbiddenError } from '../utils/errors' - -const agent = require('superagent-promise')(require('superagent'), Promise) - -/* - * checkParamsValidity: ok - * onChannelCreate: ok - * onChannelUpdate: ok - * onChannelDelete: default - * onWebhookChecking: default - * checkSecurity: ok - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: ok - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - -export default class Kik extends Template { - - static checkParamsValidity (channel) { +import { logger, arrayfy } from '../../utils' +import AbstractChannelIntegration from '../abstract_channel_integration' +import { BadRequestError, ForbiddenError } from '../../utils/errors' + +const agent = require('superagent-promise')(request, Promise) + +export default class Kik extends AbstractChannelIntegration { + + validateChannelObject (channel) { if (!channel.apiKey) { throw new BadRequestError('Parameter apiKey is missing') } else if (!channel.userName) { @@ -34,7 +17,7 @@ export default class Kik extends Template { } } - static async onChannelCreate (channel) { + async beforeChannelCreated (channel) { const data = { webhook: channel.webhook, features: { @@ -46,40 +29,37 @@ export default class Kik extends Template { } try { - await new Promise((resolve, reject) => { - request.post('https://api.kik.com/v1/config') - .auth(channel.userName, channel.apiKey) - .send(data) - .end((err) => err ? reject(err) : resolve()) - }) + await agent.post('https://api.kik.com/v1/config') + .auth(channel.userName, channel.apiKey) + .send(data) channel.isErrored = false } catch (err) { - Logger.info('[Kik] Cannot set the webhook') channel.isErrored = true + throw new BadRequestError('Invalid user name or API key') } } - static onChannelUpdate = Kik.onChannelCreate + afterChannelUpdated (channel) { + return this.beforeChannelCreated(channel) + } - static checkSecurity (req, res, channel) { - if (`https://${req.headers.host}/connect/v1/webhook/${channel._id}` !== channel.webhook || req.headers['x-kik-username'] !== channel.userName) { + authenticateWebhookRequest (req, res, channel) { + if (req.headers['x-kik-username'] !== channel.userName) { throw new ForbiddenError() } - - res.status(200).send() } - static extractOptions (req) { + populateMessageContext (req) { return { chatId: _.get(req, 'body.messages[0].chatId'), senderId: _.get(req, 'body.messages[0].participants[0]'), } } - static async sendIsTyping (channel, options) { + async onIsTyping (channel, context) { const message = { - to: options.senderId, - chatId: options.chatId, + to: context.senderId, + chatId: context.chatId, type: 'is-typing', isTyping: true, } @@ -89,7 +69,7 @@ export default class Kik extends Template { .send({ messages: [message] }) } - static parseChannelMessage (conversation, message, opts) { + parseIncomingMessage (conversation, message) { message = _.get(message, 'messages[0]', {}) const msg = { attachment: {}, channelType: 'kik' } @@ -110,10 +90,17 @@ export default class Kik extends Template { throw new BadRequestError('Message non-supported by Kik') } - return [conversation, msg, { ...opts, mentioned: true }] + return msg + } + + static formatButtons (buttons = []) { + return { + type: 'suggested', + responses: buttons.map(b => ({ type: 'text', body: b.title })), + } } - static formatMessage (conversation, message, opts) { + formatOutgoingMessage (conversation, message, opts) { const content = _.get(message, 'attachment.content') const type = _.get(message, 'attachment.type') const msg = { chatId: opts.chatId, to: opts.senderId, type } @@ -134,41 +121,35 @@ export default class Kik extends Template { } }) - replies[replies.length - 1].keyboards = [{ - type: 'suggested', - responses: content.buttons.map(b => ({ type: 'text', body: b.title })), - }] + const keyboard = Kik.formatButtons([].concat(...content.elements.map(elem => elem.buttons))) + replies[replies.length - 1].keyboards = [keyboard] return replies } + case 'buttons': case 'quickReplies': { + const keyboard = Kik.formatButtons(content.buttons) return { ...msg, type: 'text', body: content.title, - keyboards: [{ - type: 'suggested', - responses: content.buttons.map(b => ({ type: 'text', body: b.title })), - }], + keyboards: [keyboard], } } case 'card': { const replies = [] - const keyboard = { type: 'suggested' } - keyboard.responses = content.buttons.map(b => ({ type: 'text', body: b.title })) replies.push({ ...msg, type: 'text', body: content.title }) if (content.imageUrl) { replies.push({ ...msg, type: 'picture', picUrl: content.imageUrl }) } + const keyboard = Kik.formatButtons(content.buttons) replies[replies.length - 1].keyboards = [keyboard] return replies } case 'carousel': case 'carouselle': { const replies = [] - const keyboard = { type: 'suggested' } - keyboard.responses = [].concat.apply([], content.map(c => c.buttons)).map(b => ({ type: 'text', body: b.title })) for (const card of content) { replies.push({ ...msg, type: 'text', body: card.title }) @@ -178,15 +159,20 @@ export default class Kik extends Template { } } + const buttons = [].concat.apply([], content.map(c => c.buttons)) + const keyboard = Kik.formatButtons(buttons) replies[replies.length - 1].keyboards = [keyboard] return replies } + case 'custom': { + return _.map(content, ({ type, ...replyProps }) => ({ ...replyProps, ...msg, type })) + } default: throw new BadRequestError('Message type non-supported by Kik') } } - static async sendMessage (conversation, messages) { + async sendMessage (conversation, messages) { for (const message of arrayfy(messages)) { await agent('POST', 'https://api.kik.com/v1/message') .auth(conversation.channel.userName, conversation.channel.apiKey) @@ -194,4 +180,32 @@ export default class Kik extends Template { } } + populateParticipantData (participant, channel) { + return new Promise(async (resolve, reject) => { + request.get(`https://api.kik.com/v1/user/${participant.senderId}`) + .auth(channel.userName, channel.apiKey) + .end((err, result) => { + if (err) { + logger.error(`[Kik] Error when retrieving user info: ${err}`) + return reject(err) + } + + participant.data = result.body + participant.markModified('data') + + participant.save().then(resolve).catch(reject) + }) + }) + } + + parseParticipantDisplayName (participant) { + const informations = {} + + if (participant.data) { + const { firstName, lastName } = participant.data + informations.userName = `${firstName} ${lastName}` + } + + return informations + } } diff --git a/src/channel_integrations/kik/index.js b/src/channel_integrations/kik/index.js new file mode 100644 index 0000000..ee1f28e --- /dev/null +++ b/src/channel_integrations/kik/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['kik'] } diff --git a/src/channel_integrations/kik/test/integration.js b/src/channel_integrations/kik/test/integration.js new file mode 100644 index 0000000..d855e74 --- /dev/null +++ b/src/channel_integrations/kik/test/integration.js @@ -0,0 +1,279 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' + +const expect = chai.expect +const should = chai.should() + +const channelCreationParams = { + type: 'kik', + slug: 'my-awesome-channel', + userName: 'abcdefg-alias-id', + apiKey: 'oauth-token', +} + +describe('Kik channel', () => { + + const { createChannel, updateChannel, + deleteChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + const kikAPI = 'https://api.kik.com' + beforeEach(() => { + nock(kikAPI).post('/v1/config').reply(200, {}) + }) + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.token = 'newtoken' + nock(kikAPI).post('/v1/config').reply(200, {}) + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.token).to.equal(newValues.token) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should return 401 with invalid token', async () => { + try { + const headers = { 'x-kik-username': 'invalid-token' } + await sendMessageToWebhook(channel, { message: 'message' }, headers) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + it('should return 401 without token', async () => { + try { + await sendMessageToWebhook(channel, {}) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + it('should be successful with valid parameters', async () => { + const senderId = 'sender-id' + const message = { + messages: [{ + type: 'text', + body: 'a message', + chatId: 123, + participants: [senderId] }], + } + // isTyping call + nock('https://api.kik.com').post('/v1/message').reply(200, {}) + nock('https://api.kik.com').get(`/v1/user/${senderId}`).times(2).reply(200, {}) + const outgoingMessageCall = nock('https://api.kik.com').post('/v1/message').reply(200, {}) + const headers = { 'x-kik-username': channelCreationParams.userName } + const response = await sendMessageToWebhook(channel, message, headers) + expect(response.status).to.equal(200) + expect(outgoingMessageCall.isDone()).to.be.true + }) + + it('should be successful for card message', async () => { + const senderId = 'sender-id' + const chatId = 123 + const message = { + messages: [{ + type: 'text', + body: 'a message', + chatId, + participants: [senderId], + }], + } + + // isTyping call + nock('https://api.kik.com').post('/v1/message').reply(200, {}) + nock('https://api.kik.com').get(`/v1/user/${senderId}`).times(2).reply(200, {}) + const firstOutgoingMessage = nock('https://api.kik.com') + .post('/v1/message', { + messages: [ + { + chatId, + to: senderId, + type: 'text', + body: 'title', + }, + ], + }) + .basicAuth({ + user: channel.userName, + pass: channel.apiKey, + }) + .reply(200, {}) + const secondOutgoingMessage = nock('https://api.kik.com') + .post('/v1/message', { + messages: [ + { + chatId, + type: 'picture', + to: senderId, + picUrl: 'https://img.url', + keyboards: [ + { + type: 'suggested', + responses: [ + { + type: 'text', + body: 'button title', + }, + ], + }, + ], + }, + ], + }) + .basicAuth({ + user: channel.userName, + pass: channel.apiKey, + }) + .reply(200, {}) + const headers = { 'x-kik-username': channelCreationParams.userName } + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'card', + content: { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + }, + }]), + } + const response = await sendMessageToWebhook(channel, message, headers, botResponse) + expect(response.status).to.equal(200) + expect(firstOutgoingMessage.isDone()).to.be.true + expect(secondOutgoingMessage.isDone()).to.be.true + }) + + it('should be successful for list message', async () => { + const senderId = 'sender-id' + const chatId = 123 + const message = { + messages: [{ + type: 'text', + body: 'a message', + chatId, + participants: [senderId], + }], + } + // isTyping call + nock('https://api.kik.com').post('/v1/message').reply(200, {}) + nock('https://api.kik.com').get(`/v1/user/${senderId}`).times(2).reply(200, {}) + const firstOutgoingMessage = nock('https://api.kik.com') + .post('/v1/message', { + messages: [ + { + chatId, + type: 'text', + to: senderId, + body: '\ntitle\nsubtitle\nhttps://img.url', + }, + ], + }) + .basicAuth({ + user: channel.userName, + pass: channel.apiKey, + }) + .reply(200, {}) + const secondOutgoingMessage = nock('https://api.kik.com') + .post('/v1/message', { + messages: [ + { + chatId, + type: 'text', + to: senderId, + body: '\nSecond title\nSecond subtitle\nhttps://img.url', + keyboards: [ + { + type: 'suggested', + responses: [ + { + type: 'text', + body: 'button title', + }, + ], + }, + ], + }, + ], + }) + .basicAuth({ + user: channel.userName, + pass: channel.apiKey, + }) + .reply(200, {}) + const headers = { 'x-kik-username': channelCreationParams.userName } + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'list', + content: { + elements: [ + { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + }, + { + title: 'Second title', + subtitle: 'Second subtitle', + imageUrl: 'https://img.url', + buttons: [], + }, + ], + }, + }]), + } + const response = await sendMessageToWebhook(channel, message, headers, botResponse) + expect(response.status).to.equal(200) + expect(firstOutgoingMessage.isDone()).to.be.true + expect(secondOutgoingMessage.isDone()).to.be.true + }) + + }) +}) diff --git a/src/channel_integrations/line/channel.js b/src/channel_integrations/line/channel.js new file mode 100644 index 0000000..521ba66 --- /dev/null +++ b/src/channel_integrations/line/channel.js @@ -0,0 +1,270 @@ +import _ from 'lodash' +import { createHmac } from 'crypto' + +import AbstractChannelIntegration from '../abstract_channel_integration' +import { + lineSendMessage, + lineGetUserProfile, + BadRequestError, + ForbiddenError, + StopPipeline, +} from '../../utils' + +const labelCharacterLimit = 20 +const carouselLabelCharacteLimit = 12 + +export default class Line extends AbstractChannelIntegration { + + validateChannelObject (channel) { + if (!channel.token || !channel.clientSecret) { + throw new BadRequestError('Parameter token or clientSecret is missing') + } + } + + authenticateWebhookRequest (req, res, channel) { + const signature = _.get(req, ['headers', 'x-line-signature']) + const rawBody = _.get(req, 'rawBody') + const channelSecret = _.get(channel, 'clientSecret') + + const computedSignature = createHmac('SHA256', channelSecret) + .update(rawBody) + .digest('base64') + + if (signature !== computedSignature) { + throw new ForbiddenError() + } + } + + onWebhookCalled (req, res, channel) { + if (_.get(req, 'body.events[0].replyToken') === '00000000000000000000000000000000') { + // webhook verification request, reply with success, don't send a message + throw new StopPipeline() + } + + return channel + } + + populateMessageContext (req) { + const sourceType = _.get(req, 'body.events[0].source.type') + const recipientId + = sourceType === 'user' ? '' : _.get(req, `body.events[0].source.${sourceType}Id`) + const senderId = _.get(req, 'body.events[0].source.userId', recipientId) + + return { + chatId: `${recipientId}-${senderId}`, + senderId, + } + } + + updateConversationContextFromMessage (conversation, message) { + const eventType = _.get(message, 'events[0].type') + if (eventType !== 'message' && eventType !== 'postback') { + throw new StopPipeline() + } + + const replyToken = _.get(message, 'events[0].replyToken') + conversation.replyToken = replyToken + conversation.markModified('replyToken') + + return conversation + } + + parseIncomingMessage (conversation, message) { + const msg = {} + const eventType = _.get(message, 'events[0].type') + + if (eventType === 'message') { + message = _.get(message, 'events[0].message') + const type = _.get(message, 'type') + + if (type === 'text') { + msg.attachment = { + type: 'text', + content: message.text, + } + } else if (type === 'image') { + msg.attachment = { + type: 'picture', + content: message.id, + } + } else if (type === 'video') { + msg.attachment = { + type: 'video', + content: message.id, + } + } else { + throw new BadRequestError('Message type non-supported by Line') + } + } else if (eventType === 'postback') { + const content = _.get(message, 'postback') + msg.attachment = { type: 'payload', content } + } + + return msg + } + + static formatButtons (buttons, characterLimit = labelCharacterLimit) { + return buttons.map(button => { + const { title: label } = button + const type = button.type || 'text' + const value = button.value || button.url + + // Line is restrictive in terms of label length. Different lengths + // are allowed for carousel (12 chars) vs. other templates (e.g. buttons) + // see https://developers.line.me/en/reference/messaging-api/#action-objects + if (['text', 'phonenumber', 'element_share'].indexOf(type) !== -1) { + return { type: 'message', label: label.slice(0, characterLimit), text: value } + } else if (type === 'web_url') { + return { type: 'uri', label: label.slice(0, characterLimit), uri: value } + } else if (type === 'postback') { + return { type, label: label.slice(0, characterLimit), data: value, text: value } + } + return { type } + }) + } + + formatOutgoingMessage (conversation, message) { + const { type, content } = _.get(message, 'attachment') + + if (type === 'text') { + return { + type, + text: content, + } + } else if (type === 'picture') { + return { + type: 'image', + originalContentUrl: content, + previewImageUrl: content, + } + } else if (type === 'video') { + return { + type: 'video', + originalContentUrl: content, + // needs preview image + previewImageUrl: + 'https://portfolium.cloudimg.io/s/crop/128x128/' + + 'https://cdn.portfolium.com/img%2Fdefaults%2Fdefault.jpg', + } + } else if (type === 'card') { + const { title, imageUrl: thumbnailImageUrl, subtitle: text, buttons } = content + const actions = Line.formatButtons(buttons) + + return { + type: 'template', + altText: title, + template: { + type: 'buttons', + thumbnailImageUrl, + title, + text, + actions, + }, + } + } else if (type === 'quickReplies' || type === 'buttons') { + const templateType = type === 'buttons' ? type : 'confirm' + const { title, buttons } = content + const actions = Line.formatButtons(buttons) + + return { + type: 'template', + altText: title, + template: { + type: templateType, + text: title, + actions, + }, + } + } else if (type === 'list') { + const { elements, buttons } = content + const actions = Line.formatButtons(buttons) + + return _.map(elements, ({ title, imageUrl: thumbnailImageUrl, subtitle: text, buttons }) => { + const actions = Line.formatButtons(buttons) + return { + type: 'template', + altText: title, + template: { + type: 'buttons', + thumbnailImageUrl, + title, + text, + actions, + }, + } + }) + .concat([{ + type: 'template', + altText: 'actions', + template: { + type: 'confirm', + text: 'Confirm', + actions, + }, + }]) + } else if (type === 'carousel' || type === 'carouselle') { + const elements = _.map(content, ({ title, + imageUrl: thumbnailImageUrl, + subtitle: text, + buttons }) => { + const actions = Line.formatButtons(buttons, carouselLabelCharacteLimit) + return { + thumbnailImageUrl, + title, + text, + actions, + } + }) + + return { + type: 'template', + altText: 'carousel', + template: { + type: 'carousel', + columns: elements, + }, + } + } else if (type === 'custom') { + return content + } + + throw new BadRequestError('Message type non-supported by Line') + } + + async sendMessages (conversation, messages) { + await lineSendMessage(conversation.channel.token, conversation.replyToken, messages) + return true + } + + async sendMessage (conversation, message) { + await lineSendMessage(conversation.channel.token, conversation.replyToken, [message]) + return true + } + + /* + * Gromit methods + */ + + async populateParticipantData (participant, channel) { + try { + const data = await lineGetUserProfile(channel.token, participant.senderId) + + participant.data = data + return participant.save() + } catch (error) { + return participant + } + } + + parseParticipantDisplayName (participant) { + const informations = {} + + if (participant.data) { + const { displayName } = participant.data + informations.userName = displayName + } + + return informations + } + +} diff --git a/src/channel_integrations/line/index.js b/src/channel_integrations/line/index.js new file mode 100644 index 0000000..2856d39 --- /dev/null +++ b/src/channel_integrations/line/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['line'] } diff --git a/src/channel_integrations/line/test/integration.js b/src/channel_integrations/line/test/integration.js new file mode 100644 index 0000000..0273173 --- /dev/null +++ b/src/channel_integrations/line/test/integration.js @@ -0,0 +1,255 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' + +/* eslint max-nested-callbacks: 0 */ // --> OFF + +const expect = chai.expect +const should = chai.should() + +const lineAPI = 'https://api.line.me' + +const channelCreationParams = { + type: 'line', + slug: 'my-awesome-channel', + clientSecret: 'client-secret', + token: 'token', +} + +describe('Line channel', () => { + + const { createChannel, updateChannel, + deleteChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.token = 'newtoken' + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.token).to.equal(newValues.token) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('webhook url verification', () => { + let channel + + const body = { + events: [ + { + replyToken: '00000000000000000000000000000000', + type: 'message', + timestamp: 1533154101667, + source: { + type: 'user', + userId: 'Udeadbeefdeadbeefdeadbeefdeadbeef', + }, + message: { + id: '100001', + type: 'text', + text: 'Hello, world', + }, + }, + { + replyToken: 'ffffffffffffffffffffffffffffffff', + type: 'message', + timestamp: 1533154101667, + source: { + type: 'user', + userId: 'Udeadbeefdeadbeefdeadbeefdeadbeef', + }, + message: { + id: 100002, + type: 'sticker', + packageId: '1', + stickerId: '1', + }, + }, + ], + } + const headers = { 'x-line-signature': 'AziZnbmnjOTxmYKhlJrQLeFfmuybTQmywm7p5dT5UEc=' } + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful and not send any message', async () => { + const response = await sendMessageToWebhook(channel, body, headers) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + const body = { events: [{ + type: 'message', + source: { type: 'user', userId: 'userid' }, + message: { type: 'text', text: 'a test message' }, + }] } + const headers = { 'x-line-signature': 'syDEWaFckSt/T1qAy0R/WCVWygSSOWcJrPs61aGFdIg=' } + + it('should be successful with valid token', async () => { + nock(lineAPI).get('/v2/bot/profile/userid').reply(200, {}) + const outgoingMessageCall = nock(lineAPI).post('/v2/bot/message/reply').reply(200, {}) + const response = await sendMessageToWebhook(channel, body, headers) + expect(response.status).to.equal(200) + expect(outgoingMessageCall.isDone()).to.be.true + }) + + it('should return 401 with invalid token', async () => { + try { + const headers = { 'x-twilio-signature': 'invalid' } + await sendMessageToWebhook(channel, {}, headers) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + it('should return 401 without a token', async () => { + try { + await sendMessageToWebhook(channel, {}) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + describe('should be successful', () => { + + beforeEach(async () => { + nock(lineAPI).get('/v2/bot/profile/userid').reply(200, {}) + nock(lineAPI).post('/v2/bot/message/reply').reply(200, {}) + + }) + + it('in list format', async () => { + const buttons = [ + { type: 'account_link', title: 'button title', value: 'https://link.com' }, + { type: 'web_url', title: 'button title', value: 'https://link.com' }, + { type: 'phone_number', title: 'button title', value: '0123554' }] + const listElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons, + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'list', + content: { elements: [listElement], buttons }, + }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in card format', async () => { + const cardElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'card', content: cardElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in carousel format', async () => { + const carouselElement = { + title: 'title', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'carousel', content: [carouselElement] }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in quickReplies format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'quickReplies', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in video format', async () => { + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'video', content: 'https://link.com' }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in buttons format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'buttons', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + }) + + }) +}) diff --git a/src/services/Microsoft.service.js b/src/channel_integrations/microsoft/channel.js similarity index 63% rename from src/services/Microsoft.service.js rename to src/channel_integrations/microsoft/channel.js index 7f59a9f..c396441 100644 --- a/src/services/Microsoft.service.js +++ b/src/channel_integrations/microsoft/channel.js @@ -1,36 +1,22 @@ import _ from 'lodash' -import { URL } from 'url' -import { Message, HeroCard, CardAction, CardImage, ThumbnailCard, AttachmentLayout } from 'botbuilder' - -import Template from './Template.service' -import { BadRequestError, StopPipeline } from '../utils/errors' -import { Logger, microsoftParseMessage, microsoftGetBot, microsoftMakeAttachement } from '../utils' - -/* - * checkParamsValidity: ok - * onChannelCreate: default - * onChannelUpdate: default - * onChannelDelete: default - * onWebhookChecking: default - * checkSecurity: default - * beforePipeline: default - * extractOptions: ok - * getRawMessage: ok - * sendIsTyping: default - * updateConversationWithMessage: ok - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ +import url from 'url' +import { + Message, HeroCard, CardAction, + CardImage, ThumbnailCard, AttachmentLayout } from 'botbuilder' + +import AbstractChannelIntegration from '../abstract_channel_integration' +import { logger } from '../../utils' +import { BadRequestError, StopPipeline } from '../../utils/errors' +import { microsoftParseMessage, microsoftGetBot, microsoftMakeAttachement } from './utils' const VIDEO_AS_LINK_HOSTS = [ 'youtube.com', 'youtu.be', ] -export default class MicrosoftTemplate extends Template { +export default class MicrosoftTemplate extends AbstractChannelIntegration { - static checkParamsValidity (channel) { + validateChannelObject (channel) { const params = ['clientId', 'clientSecret'] params.forEach(param => { if (!channel[param] || typeof channel[param] !== 'string') { @@ -39,7 +25,11 @@ export default class MicrosoftTemplate extends Template { }) } - static async extractOptions (req, res, channel) { + validateWebhookSubscriptionRequest (req, res) { + res.status(200).send() + } + + async populateMessageContext (req, res, channel) { const { session, message } = await microsoftParseMessage(channel, req) return { @@ -50,22 +40,21 @@ export default class MicrosoftTemplate extends Template { } } - static sendIsTyping (channel, options) { - options.session.sendTyping() + onIsTyping (channel, context) { + context.session.sendTyping() } - static getRawMessage (channel, req, options) { - return options.message + getRawMessage (channel, req, context) { + return context.message } - static async updateConversationWithMessage (conversation, message, opts) { + updateConversationContextFromMessage (conversation, message) { conversation.microsoftAddress = message.address conversation.markModified('microsoftAddress') - - return Promise.all([conversation.save(), message, opts]) + return conversation } - static async parseChannelMessage (conversation, message, opts) { + parseIncomingMessage (conversation, message) { const msg = {} const attachment = _.get(message, 'attachments[0]') if (attachment) { @@ -74,10 +63,10 @@ export default class MicrosoftTemplate extends Template { } else if (attachment.contentType.startsWith('video')) { msg.attachment = { type: 'video', content: attachment.contentUrl } } else { - Logger.info('No support for files of type : '.concat(attachment.contentType)) - Logger.info('Defaulting to text') + logger.warning('[Microsoft] No support for files of type: '.concat(attachment.contentType)) + logger.info('[Microsoft] Defaulting to text') if (!message.text || message.text.length <= 0) { - Logger.error('No text') + logger.error('[Microsoft] No text') throw new StopPipeline() } msg.attachment = { type: 'text', content: message.text } @@ -85,10 +74,10 @@ export default class MicrosoftTemplate extends Template { } else { msg.attachment = { type: 'text', content: message.text } } - return Promise.all([conversation, msg, { ...opts, mentioned: true }]) + return msg } - static async formatMessage (conversation, message, opts) { + async formatOutgoingMessage (conversation, message, opts) { const { type, content } = _.get(message, 'attachment') const msg = new Message() const mType = _.get(conversation, 'microsoftAddress.channelId', '') @@ -99,7 +88,7 @@ export default class MicrosoftTemplate extends Template { } const makeCard = (constructor, e) => { - return new constructor() + const res = new constructor() .title(e.title) .subtitle(e.subtitle) .images([CardImage.create(undefined, e.imageUrl)]) @@ -110,22 +99,32 @@ export default class MicrosoftTemplate extends Template { } return fun(undefined, button.value, button.title) })) + if (e.onClick) { + let fun = CardAction.imBack + if (['web_url', 'account_linking'].indexOf(e.onClick.type) !== -1) { + fun = CardAction.openUrl + } + res.tap(fun(undefined, e.onClick.value, e.onClick.title || '')) + } + return res } if (type === 'text') { msg.text(content) } else if (type === 'picture' || type === 'video') { - let hostname = (new URL(content)).hostname + let hostname = url.parse(content).hostname if (hostname.startsWith('www.')) { hostname = hostname.slice(4, hostname.length) } - if (type === 'video' && (VIDEO_AS_LINK_HOSTS.indexOf(hostname) !== -1 || opts.allVideosAsLink)) { + if (type === 'video' + && (VIDEO_AS_LINK_HOSTS.indexOf(hostname) !== -1 + || opts.allVideosAsLink)) { msg.text(content) } else { const attachment = await microsoftMakeAttachement(content) msg.addAttachment(attachment) } - } else if (type === 'quickReplies') { + } else if (type === 'quickReplies' || type === 'buttons') { const attachment = new HeroCard() .title(content.title) .buttons(content.buttons.map(button => { @@ -161,11 +160,18 @@ export default class MicrosoftTemplate extends Template { return msg } - static async sendMessage (conversation, message) { - const channel = conversation.channel - const bot = microsoftGetBot(channel) - const address = conversation.microsoftAddress - bot.send(message.address(address)) + sendMessage (conversation, message) { + return new Promise((resolve, reject) => { + const channel = conversation.channel + const bot = microsoftGetBot(channel) + const address = conversation.microsoftAddress + bot.send(message.address(address), (err) => { + if (err) { + return reject(err) + } + return resolve() + }) + }) } } diff --git a/src/channel_integrations/microsoft/index.js b/src/channel_integrations/microsoft/index.js new file mode 100644 index 0000000..d46c5ca --- /dev/null +++ b/src/channel_integrations/microsoft/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['microsoft'] } diff --git a/src/channel_integrations/microsoft/test/integration.js b/src/channel_integrations/microsoft/test/integration.js new file mode 100644 index 0000000..3509150 --- /dev/null +++ b/src/channel_integrations/microsoft/test/integration.js @@ -0,0 +1,59 @@ +import chai from 'chai' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' +import { setupChannelIntegrationTests } from '../../../../test/tools' + +const agent = superagentPromise(superagent, Promise) +const expect = chai.expect +const should = chai.should() +const channelCreationParams = { + type: 'microsoft', + slug: 'my-awesome-channel', + clientId: 'client-id', + clientSecret: 'client-secret', +} + +describe('Microsoft channel', () => { + + const { createChannel } = setupChannelIntegrationTests() + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + }) + + it('should return 400 for valid parameters', async () => { + try { + await createChannel({ type: 'microsoft', slug: 'my-awesome-channel' }) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('test webchat in Azure portal', () => { + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful for pulse check', async () => { + const response = await agent.get(channel.webhook) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + it('should be successful with valid parameters') + + }) +}) diff --git a/src/utils/microsoft.js b/src/channel_integrations/microsoft/utils.js similarity index 90% rename from src/utils/microsoft.js rename to src/channel_integrations/microsoft/utils.js index 30c341d..e3b7760 100644 --- a/src/utils/microsoft.js +++ b/src/channel_integrations/microsoft/utils.js @@ -1,4 +1,6 @@ -import { ChatConnector, UniversalBot } from 'botbuilder' +// @flow + +import { ChatConnector, UniversalBot, MemoryBotStorage } from 'botbuilder' import fileType from 'file-type' import http from 'http' import https from 'https' @@ -34,6 +36,7 @@ export function microsoftParseMessage (channel, req) { const bot = new UniversalBot(connector, (session) => { resolve({ session, message: session.message }) }) + .set('storage', new MemoryBotStorage()) bot.linterPleaseLeaveMeAlone = 1 }) } @@ -43,7 +46,7 @@ export function microsoftGetBot (channel) { appId: channel.clientId, appPassword: channel.clientSecret, }) - const bot = new UniversalBot(connector) + const bot = new UniversalBot(connector).set('storage', new MemoryBotStorage()) return bot } diff --git a/src/services/Slack.service.js b/src/channel_integrations/slack/channel.js similarity index 52% rename from src/services/Slack.service.js rename to src/channel_integrations/slack/channel.js index 59fab6f..16d4899 100644 --- a/src/services/Slack.service.js +++ b/src/channel_integrations/slack/channel.js @@ -1,74 +1,76 @@ import _ from 'lodash' import request from 'superagent' -import Logger from '../utils/Logger' -import Template from './Template.service' -import { BadRequestError } from '../utils/errors' - -/* - * checkParamsValidity: ok - * onChannelCreate: default - * onChannelUpdate: default - * onChannelDelete: default - * onWebhookChecking: default - * checkSecurity: default - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: default - * formatMessage: ok - * sendMessage: ok - */ - -export default class Slack extends Template { - - static extractOptions (req) { +import AbstractChannelIntegration from '../abstract_channel_integration' +import { logger } from '../../utils' +import { BadRequestError, UnauthorizedError } from '../../utils/errors' + +export default class Slack extends AbstractChannelIntegration { + + populateMessageContext (req) { return { chatId: _.get(req, 'body.event.channel'), senderId: _.get(req, 'body.event.user'), } } - static checkParamsValidity (channel) { + validateChannelObject (channel) { if (!channel.token) { throw new BadRequestError('Parameter token is missing') } } - static parseChannelMessage (conversation, message, opts) { + parseIncomingMessage (conversation, message, opts) { const msg = { attachment: {} } const file = _.get(message, 'event.file', { mimetype: '' }) opts.mentioned = _.get(message, 'event.channel', '').startsWith('D') || _.get(message, 'event.text', '').includes(`<@${conversation.channel.botuser}>`) - Logger.inspect(message) - if (file.mimetype.startsWith('image')) { _.set(msg, 'attachment', { type: 'picture', content: file.url_private }) } else if (file.mimetype.startsWith('video')) { _.set(msg, 'attachment', { type: 'picture', content: file.url_private }) } else if (message.event && message.event.text) { - _.set(msg, 'attachment', { type: 'text', content: message.event.text.replace(`<@${conversation.channel.botuser}>`, '') }) + _.set(msg, 'attachment', { + type: 'text', + content: message.event.text.replace(`<@${conversation.channel.botuser}>`, ''), + }) } else { throw new BadRequestError('Message type non-supported by Slack') } - return [conversation, msg, opts] + return msg } - static formatMessage (conversation, message) { + formatOutgoingMessage (conversation, message) { const type = _.get(message, 'attachment.type') const content = _.get(message, 'attachment.content') + const makeButton = ({ type, title, value }) => { + const button = { name: title, text: title, type: 'button' } + if (type === 'web_url') { + button.url = value + } else { + button.value = value + } + return button + } switch (type) { case 'text': - case 'video': - case 'picture': { + case 'video': { return { text: content } } + case 'picture': { + return { + attachments: [ + { + fallback: content, + image_url: content, + }, + ], + } + } case 'list': { return { attachments: content.elements.map(e => ({ @@ -78,10 +80,11 @@ export default class Slack extends Template { image_url: e.imageUrl, attachment_type: 'default', callback_id: 'callback_id', - actions: e.buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + actions: e.buttons.map(makeButton), })), } } + case 'buttons': case 'quickReplies': { const { title, buttons } = content return { @@ -89,9 +92,9 @@ export default class Slack extends Template { attachments: [{ fallback: title, color: '#3AA3E3', - attachemnt_type: 'default', + attachment_type: 'default', callback_id: 'callback_id', - actions: buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + actions: buttons.map(makeButton), }], } } @@ -105,7 +108,7 @@ export default class Slack extends Template { fallback: content.title, attachment_type: 'default', callback_id: 'callback_id', - actions: content.buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + actions: content.buttons.map(makeButton), }], } } @@ -118,15 +121,17 @@ export default class Slack extends Template { image_url: card.imageUrl, attachment_type: 'default', callback_id: 'callback_id', - actions: card.buttons.map(({ title, value }) => ({ name: title, text: title, type: 'button', value })), + actions: card.buttons.map(makeButton), })), } + case 'custom': + return content default: throw new BadRequestError('Message type non-supported by Slack') } } - static sendMessage (conversation, message) { + sendMessage (conversation, message) { return new Promise((resolve, reject) => { const req = request.post('https://slack.com/api/chat.postMessage') .query({ token: conversation.channel.token, channel: conversation.chatId, as_user: true }) @@ -139,8 +144,45 @@ export default class Slack extends Template { req.query({ attachments: JSON.stringify(message.attachments) }) } - req.end((err) => err ? reject(err) : resolve('Message sent')) + req.end((err, res) => { + if (err) { + logger.error(`[Slack] Error sending message: ${err}`) + reject(err) + } else if (!res.body.ok) { + // might come back with { ok: false, error: 'invalid_auth' } + logger.error('[Slack] Error sending message: ', res.body) + reject(new UnauthorizedError('Invalid authentication information for Slack')) + } else { + resolve('Message sent') + } + }) + }) + } + + populateParticipantData (participant, channel) { + return new Promise((resolve, reject) => { + const token = channel.token + const senderId = participant.senderId + + request.get(`http://slack.com/api/users.info?token=${token}&user=${senderId}`) + .end((err, res) => { + if (err) { + logger.error(`Error when retrieving Slack user info: ${err}`) + return reject(err) + } + + participant.data = res.body && res.body.user + participant.markModified('data') + + participant.save().then(resolve).catch(reject) + }) }) } + parseParticipantDisplayName (participant) { + return participant.data + ? { userName: participant.data.real_name } + : {} + } + } diff --git a/src/channel_integrations/slack/index.js b/src/channel_integrations/slack/index.js new file mode 100644 index 0000000..535f5e7 --- /dev/null +++ b/src/channel_integrations/slack/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['slack'] } diff --git a/src/channel_integrations/slack/test/integration.js b/src/channel_integrations/slack/test/integration.js new file mode 100644 index 0000000..7ece523 --- /dev/null +++ b/src/channel_integrations/slack/test/integration.js @@ -0,0 +1,348 @@ +import _ from 'lodash' +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import superagentPromise from 'superagent-promise' +import superagent from 'superagent' +import nock from 'nock' + +/* eslint max-nested-callbacks: 0 */ // --> OFF +/* eslint no-unused-expressions: 0 */ // --> OFF + +const agent = superagentPromise(superagent, Promise) +const expect = chai.expect + +describe('Slack channel', () => { + + const { createChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + let slackAppChannel + const teamId = 'slack-team' + + beforeEach(async () => { + const response = await createChannel({ + type: 'slackapp', + slug: 'my-awesome-channel', + clientId: 'client-id', + clientSecret: 'client-secret', + }) + slackAppChannel = response.body.results + }) + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const slackOauthResponse = { + ok: true, + team_id: teamId, + bot: { + bot_user_id: 'bot-user-id', + bot_access_token: 'bot-access-token', + }, + } + nock('https://slack.com').post('/api/oauth.access').query(true).reply(200, slackOauthResponse) + + // Call oauth endpoint on Slack App channel to create Slack channel + const oauthResponse = await agent.get(slackAppChannel.oAuthUrl) + .query({ code: 'activation-code' }) + .send({}) + expect(oauthResponse.status).to.equal(200) + // Get Slack App channel and check for child + const createChannelResponse + = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${slackAppChannel.connector}/channels/${slackAppChannel.slug}`) + .send() + expect(createChannelResponse.status).to.equal(200) + expect(createChannelResponse.body.results.children).to.have.lengthOf(1) + const slackChannel = createChannelResponse.body.results.children[0] + expect(slackChannel.botuser).to.equal(slackOauthResponse.bot.bot_user_id) + expect(slackChannel.token).to.equal(slackOauthResponse.bot.bot_access_token) + }) + }) + + describe('sending a message', () => { + + const botUser = 'W965F80RL' + const botAccessToken = 'bot-access-token' + const teamId = 'T78N3DCEN' + + beforeEach(async () => { + nock('https://slack.com').post('/api/oauth.access').query(true).reply(200, { + ok: true, + team_id: teamId, + bot: { + bot_user_id: botUser, + bot_access_token: botAccessToken, + }, + }) + nock('http://slack.com').get('/api/users.info').query(true).reply(200, {}) + + // Call oauth endpoint on Slack App channel to create Slack channel + await agent.get(slackAppChannel.oAuthUrl) + .query({ code: 'activation-code' }) + .send({ }) + }) + + const body = { + token: 'NIQiZjUhWLD9dAw54YcZbf9X', + team_id: teamId, + enterprise_id: 'A1RABCDXHA', + api_app_id: 'BABTW7MNU', + event: { + type: 'message', + user: 'another-user', + text: 'This is a test message', + client_msg_id: '2ca5568c-1ae6-4446-9419-a1f388d3289b', + team: teamId, + source_team: teamId, + user_team: teamId, + user_profile: { + avatar_hash: '5ab7b65c4c14', + image_72: 'https://avatars.slack-edge.com/2018-03-13' + + '/335522214727_5ab6b74c4c132402f9ba_72.jpg', + first_name: 'John', + real_name: 'John Doe', + display_name: 'John Doe', + team: 'E7RBBBXHB', + name: 'johndoe', + is_restricted: false, + is_ultra_restricted: false, + }, + ts: '1531523342.000005', + channel: 'DAPTXDG92', + event_ts: '1531523342.000005', + channel_type: 'im', + }, + type: 'event_callback', + authed_teams: [teamId], + event_id: 'EvBRPDDKU6', + event_time: 1531523342, + authed_users: ['WAPTX8GTA'], + } + + describe('should be successful', () => { + it('in text format', async () => { + const message = 'my text message' + const apiCall = nock('https://slack.com').post('/api/chat.postMessage') + .query((actualQuery) => { + const expectedQuery = { + token: botAccessToken, + as_user: 'true', + channel: body.event.channel, + text: message, + } + return _.isEqual(expectedQuery, actualQuery) + }) + .reply(200, { + ok: true, + }) + const botResponse = { + messages: JSON.stringify([{ type: 'text', content: message }]), + } + const response = await sendMessageToWebhook(slackAppChannel, body, {}, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + + it('in list format', async () => { + const listElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const apiCall = nock('https://slack.com').post('/api/chat.postMessage') + .query((actualQuery) => { + actualQuery.attachments = JSON.parse(actualQuery.attachments) + const expectedQuery = { + token: botAccessToken, + as_user: 'true', + channel: body.event.channel, + attachments: [{ + color: '#3AA3E3', + attachment_type: 'default', + callback_id: 'callback_id', + title: listElement.title, + image_url: listElement.imageUrl, + text: listElement.subtitle, + actions: [{ + name: listElement.buttons[0].title, + text: listElement.buttons[0].title, + type: 'button', + value: listElement.buttons[0].value, + }], + }], + } + return _.isEqual(expectedQuery, actualQuery) + }) + .reply(200, { + ok: true, + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'list', content: { elements: [listElement] } }]), + } + const response = await sendMessageToWebhook(slackAppChannel, body, {}, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + + it('in card format', async () => { + const cardElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const apiCall = nock('https://slack.com').post('/api/chat.postMessage') + .query((actualQuery) => { + actualQuery.attachments = JSON.parse(actualQuery.attachments) + const expectedQuery = { + token: botAccessToken, + as_user: 'true', + channel: body.event.channel, + attachments: [{ + color: '#7CD197', + fallback: cardElement.title, + attachment_type: 'default', + callback_id: 'callback_id', + title: cardElement.title, + image_url: cardElement.imageUrl, + text: cardElement.subtitle, + actions: [{ + name: cardElement.buttons[0].title, + text: cardElement.buttons[0].title, + type: 'button', + value: cardElement.buttons[0].value, + }], + }], + } + return _.isEqual(expectedQuery, actualQuery) + }) + .reply(200, { + ok: true, + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'card', content: cardElement }]), + } + const response = await sendMessageToWebhook(slackAppChannel, body, {}, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + + it('in carousel format', async () => { + const carouselElement = { + title: 'title', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const apiCall = nock('https://slack.com').post('/api/chat.postMessage') + .query((actualQuery) => { + actualQuery.attachments = JSON.parse(actualQuery.attachments) + const expectedQuery = { + token: botAccessToken, + as_user: 'true', + channel: body.event.channel, + attachments: [{ + color: '#F35A00', + attachment_type: 'default', + callback_id: 'callback_id', + title: carouselElement.title, + image_url: carouselElement.imageUrl, + actions: [{ + name: carouselElement.buttons[0].title, + text: carouselElement.buttons[0].title, + type: 'button', + value: carouselElement.buttons[0].value, + }], + }], + } + return _.isEqual(expectedQuery, actualQuery) + }) + .reply(200, { + ok: true, + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'carousel', content: [carouselElement] }]), + } + const response = await sendMessageToWebhook(slackAppChannel, body, {}, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + + it('in buttons', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const apiCall = nock('https://slack.com').post('/api/chat.postMessage') + .query((actualQuery) => { + actualQuery.attachments = JSON.parse(actualQuery.attachments) + const expectedQuery = { + token: botAccessToken, + as_user: 'true', + channel: body.event.channel, + text: buttonsElement.title, + attachments: [{ + fallback: buttonsElement.title, + color: '#3AA3E3', + attachment_type: 'default', + callback_id: 'callback_id', + actions: [{ + name: buttonsElement.buttons[0].title, + text: buttonsElement.buttons[0].title, + type: 'button', + value: buttonsElement.buttons[0].value, + }], + }], + } + return _.isEqual(expectedQuery, actualQuery) + }) + .reply(200, { + ok: true, + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'buttons', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(slackAppChannel, body, {}, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + + it('with response message type picture', async () => { + const imageUrl = 'https://url.to/image.png' + const apiCall = nock('https://slack.com') + .post('/api/chat.postMessage') + .query((actualQuery) => { + actualQuery.attachments = JSON.parse(actualQuery.attachments) + const expectedQuery = { + token: botAccessToken, + as_user: 'true', + channel: body.event.channel, + attachments: [{ + fallback: imageUrl, + image_url: imageUrl, + }], + } + return _.isEqual(expectedQuery, actualQuery) + }) + .reply(200, { + ok: true, + }) + + const botResponse = { + results: {}, + messages: JSON.stringify([ + { + type: 'picture', + content: imageUrl, + }, + ]), + } + const response = await sendMessageToWebhook(slackAppChannel, body, {}, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + }) + + }) +}) diff --git a/src/channel_integrations/slack_app/channel.js b/src/channel_integrations/slack_app/channel.js new file mode 100644 index 0000000..1aaeeec --- /dev/null +++ b/src/channel_integrations/slack_app/channel.js @@ -0,0 +1,161 @@ +import _ from 'lodash' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import AbstractChannelIntegration from '../abstract_channel_integration' +import { Channel } from '../../models' +import { slugify } from '../../models/channel' +import { StopPipeline, NotFoundError, BadRequestError } from '../../utils/errors' +import * as config from '../../../config' + +const agent = superagentPromise(superagent, Promise) + +export default class SlackAppChannel extends AbstractChannelIntegration { + + populateMessageContext (req) { + return { + chatId: _.get(req, 'body.event.channel'), + senderId: _.get(req, 'body.event.user'), + } + } + + validateChannelObject (channel) { + if (!channel.clientId) { + throw new BadRequestError('Parameter clientId is missing') + } else if (!channel.clientSecret) { + throw new BadRequestError('Parameter clientSecret is missing') + } + } + + async beforeChannelCreated (channel) { + channel.oAuthUrl = `${config.base_url}/v1/oauth/slack/${channel._id}` + return channel.save() + } + + async afterChannelDeleted (channel) { + for (let child of channel.children) { + child = await Channel.findById(child) + if (child) { await child.remove() } + } + } + + authenticateWebhookRequest (req) { + if (req.body && req.body.type === 'url_verification') { + throw new StopPipeline(req.body.challenge) + } + } + + async onWebhookCalled (req, res, channel) { + // send 200 OK early so that Slack doesn't retry our web hook after 3s + // because sometimes we take more than 3s to produce and send a bot response back to Slack + res.status(200).send() + + /* handle action (buttons) to format them */ + if (req.body.payload) { + req.body = SlackAppChannel.parsePayload(req.body) + } + + /* Search for the App children */ + // slugify the slug as well to support channels created before slug was slugified on save + channel = _.find( + channel.children, child => slugify(child.slug) === slugify(req.body.team_id) + ) + if (!channel) { throw new NotFoundError('Channel') } + + /* check if event is only message */ + if (channel.type === 'slack' + && req.body + && req.body.event + && req.body.event.type !== 'message') { + throw new StopPipeline() + } + + /* check if sender is the bot */ + if (req.body.event.user === channel.botuser) { + throw new StopPipeline() + } + + return channel + } + + finalizeWebhookRequest (req, res) { + res.status(200).send() + } + + /* + * SlackApp specific methods + */ + + static parsePayload (body) { + const parsedBody = JSON.parse(body.payload) + + return ({ + team_id: parsedBody.team.id, + token: parsedBody.token, + event: { + type: 'message', + is_button_click: parsedBody.actions[0].type === 'button', + user: parsedBody.user.id, + text: parsedBody.actions[0].value, + ts: parsedBody.action_ts, + channel: parsedBody.channel.id, + event_ts: parsedBody.action_ts, + }, + type: 'event_callback', + }) + } + + static async receiveOauth (req, res) { + const { channel_id } = req.params + const { code } = req.query + const channel = await Channel.findById(channel_id) + + if (!channel) { + throw new NotFoundError('Channel') + } + + let response + try { + response = await agent.post('https://slack.com/api/oauth.access') + .query({ client_id: channel.clientId }) + .query({ client_secret: channel.clientSecret }) + .query({ code }) + } catch (err) { + throw new Error(`[Slack] Failed oAuth subscription: ${err.message}`) + } + + const { body } = response + if (!body.ok) { + throw new Error(`[Slack] Failed oAuth subscription: ${body.error}`) + } + + try { + const channelChild = await new Channel({ + type: 'slack', + app: channel_id, + slug: body.team_id, + connector: channel.connector, + botuser: body.bot.bot_user_id, + token: body.bot.bot_access_token, + }) + channel.children.push(channelChild._id) + + await Promise.all([ + channelChild.save(), + channel.save(), + ]) + } catch (err) { + throw new Error(`Error storing Mongoose model for Slack channel child: ${err.message}`) + } + + let url = `${config.cody_base_url}/` + + if (req.query.state) { + const infosSlugEncoded = Buffer.from(req.query.state, 'base64') + const infosSlugDecoded = JSON.parse(infosSlugEncoded.toString('utf8')) + const { userSlug, botSlug } = infosSlugDecoded + url = `${config.cody_base_url}/${userSlug}/${botSlug}/connect/?slack=success` + } + res.redirect(url) + } +} diff --git a/src/channel_integrations/slack_app/index.js b/src/channel_integrations/slack_app/index.js new file mode 100644 index 0000000..f03b8ef --- /dev/null +++ b/src/channel_integrations/slack_app/index.js @@ -0,0 +1,4 @@ +import channel from './channel' +import routes from './routes' + +module.exports = { channel, routes, identifiers: ['slackapp'] } diff --git a/src/channel_integrations/slack_app/routes.js b/src/channel_integrations/slack_app/routes.js new file mode 100644 index 0000000..ad80636 --- /dev/null +++ b/src/channel_integrations/slack_app/routes.js @@ -0,0 +1,11 @@ +import SlackAppChannel from './channel' + +export default [ + { + method: 'GET', + path: ['/oauth/slack/:channel_id'], + validators: [], + authenticators: [], + handler: SlackAppChannel.receiveOauth, + }, +] diff --git a/src/channel_integrations/slack_app/test/integration.js b/src/channel_integrations/slack_app/test/integration.js new file mode 100644 index 0000000..8db4e09 --- /dev/null +++ b/src/channel_integrations/slack_app/test/integration.js @@ -0,0 +1,64 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' + +const expect = chai.expect +const should = chai.should() +const channelCreationParams = { + type: 'slackapp', + slug: 'my-awesome-channel', + clientId: 'client-id', + clientSecret: 'client-secret', +} + +/* eslint no-unused-expressions: 0 */ // --> OFF + +describe('Slack App channel', () => { + + const { createChannel, updateChannel, deleteChannel } = setupChannelIntegrationTests() + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.children).to.be.empty + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + expect(result.oAuthUrl.startsWith(`${process.env.ROUTETEST}/v1/oauth/slack/`)).to.be.true + expect(result.webhook.startsWith(`${process.env.ROUTETEST}/v1/webhook/`)).to.be.true + }) + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = JSON.parse(JSON.stringify(channelCreationParams)) + newValues.clientSecret = 'newsecret' + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.clientSecret).to.equal(newValues.clientSecret) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) +}) diff --git a/src/channel_integrations/slack_app/test/routes.js b/src/channel_integrations/slack_app/test/routes.js new file mode 100644 index 0000000..a882bba --- /dev/null +++ b/src/channel_integrations/slack_app/test/routes.js @@ -0,0 +1,16 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../../../../test/tools' + +import SlackAppChannel from '../channel' +import OauthRoutes from '../routes' + +describe('Oauth Routes Testing', () => { + describe('GET /oauth/slack/:channel_id', () => { + it('should call SlackAppChannel#receiveOauth', async () => { + expect(fetchMethod(OauthRoutes, 'GET', '/oauth/slack/:channel_id')) + .to.equal(SlackAppChannel.receiveOauth) + }) + }) + +}) diff --git a/src/channel_integrations/telegram/channel.js b/src/channel_integrations/telegram/channel.js new file mode 100644 index 0000000..b03ef05 --- /dev/null +++ b/src/channel_integrations/telegram/channel.js @@ -0,0 +1,304 @@ +import _ from 'lodash' +import superAgent from 'superagent' +import superAgentPromise from 'superagent-promise' + +import AbstractChannelIntegration from '../abstract_channel_integration' +import { logger } from '../../utils' +import { + BadRequestError, + InternalServerError, + StopPipeline, + ValidationError, +} from '../../utils/errors' + +const agent = superAgentPromise(superAgent, Promise) + +export default class Telegram extends AbstractChannelIntegration { + + validateChannelObject (channel) { + if (!channel.token) { + throw new ValidationError('token', 'missing') + } + } + + async beforeChannelCreated (channel) { + const { token, webhook } = channel + + try { + await this.setWebhook(token, webhook) + channel.isErrored = false + } catch (err) { + logger.error(`[Telegram] Cannot set webhook: ${err}`) + channel.isErrored = true + } + + return channel.save() + } + + async afterChannelUpdated (channel, oldChannel) { + await this.afterChannelDeleted(oldChannel) + await this.beforeChannelCreated(channel) + } + + async afterChannelDeleted (channel) { + const { token } = channel + + try { + const { status } = await agent.get(`https://api.telegram.org/bot${token}/deleteWebhook`) + + if (status !== 200) { + throw new InternalServerError(`[Telegram][Status ${status}] Cannot delete webhook`) + } + } catch (err) { + logger.error(`[Telegram] Cannot unset the webhook: ${err}`) + channel.isErrored = true + } + } + + populateMessageContext (req) { + if (req.body.edited_message || req.body.edited_channel_post) { + throw new StopPipeline() + } + + const message = req.body.message || req.body.channel_post + return { + chatId: _.get(message, 'chat.id'), + senderId: _.get(message, 'from.id'), + } + } + + finalizeWebhookRequest (req, res) { + res.status(200).send({ status: 'success' }) + } + + parseIncomingMessage (conversation, { message, callback_query }) { + const channelType = _.get(conversation, 'channel.type') + const content = _.get(message, 'text') + + const buttonClickText = _.get(callback_query, 'data') + + if (!content && !buttonClickText) { + logger.error('[Telegram] No text field in incoming message') + throw new StopPipeline() + } + + return { + attachment: { type: 'text', content: content || buttonClickText }, + channelType, + } + } + + formatOutgoingMessage ({ channel, chatId }, { attachment }, { senderId }) { + const { type, content } = attachment + const reply = { + chatId, + type, + to: senderId, + token: _.get(channel, 'token'), + } + + switch (type) { + case 'text': + case 'video': + return { ...reply, body: content } + case 'picture': + return { ...reply, type: 'photo', body: content } + case 'card': + case 'quickReplies': + return { + ...reply, + type: 'card', + photo: _.get(content, 'imageUrl'), + body: `*${_.get(content, 'title', '')}*\n**${_.get(content, 'subtitle', '')}**`, + keyboard: tgKeyboardLayout(content.buttons.map(tgFormatButton)), + } + case 'list': + return { + ...reply, + keyboard: tgKeyboardLayout( + _.flattenDeep([ + content.buttons.map(tgFormatButton), + content.elements.map(elem => elem.buttons.map(tgFormatButton)), + ]) + ), + body: content.elements.map(e => `*- ${e.title}*\n${e.subtitle}\n${e.imageUrl || ''}`), + } + case 'carousel': + case 'carouselle': + return { + ...reply, + keyboard: tgKeyboardLayout( + _.flatten( + content.map(card => card.buttons.map(tgFormatButton)) + ) + ), + body: content.map(({ imageUrl, title, subtitle }) => + `*${title}*\n[${subtitle || ''}](${imageUrl})`), + } + case 'buttons': + return { + ...reply, + type: 'text', + keyboard: tgKeyboardLayout(content.buttons.map(tgFormatButton)), + body: _.get(content, 'title', ''), + } + case 'custom': + return { + ...reply, + body: content, + } + default: + throw new BadRequestError('Message type non-supported by Telegram') + } + } + + async sendMessage ({ channel }, { token, type, chatId, body, photo, keyboard }) { + const url = `https://api.telegram.org/bot${token}` + const method = type === 'text' ? 'sendMessage' : `send${_.capitalize(type)}` + + if (type === 'card') { + try { + if (!_.isUndefined(photo)) { + await agent.post(`${url}/sendPhoto`, { chat_id: chatId, photo }) + } + await agent.post(`${url}/sendMessage`, { + chat_id: chatId, + text: body, + reply_markup: { keyboard, one_time_keyboard: true }, + parse_mode: 'Markdown', + }) + } catch (err) { + this.logSendMessageError(err, type) + } + } else if (type === 'quickReplies') { + try { + await agent.post(`${url}/sendMessage`, { + chat_id: chatId, + text: body, + reply_markup: { keyboard, one_time_keyboard: true }, + }) + } catch (err) { + this.logSendMessageError(err, type) + } + } else if (type === 'carousel' || type === 'carouselle' || type === 'list') { + let i = 0 + for (const elem of body) { + // Send keyboard if this is the last POST request + if (i === body.length - 1) { + try { + await agent.post(`${url}/sendMessage`, { + chat_id: chatId, + text: elem, + reply_markup: { keyboard, one_time_keyboard: true }, + parse_mode: 'Markdown', + }) + } catch (err) { + this.logSendMessageError(err, type) + } + } + try { + await agent.post( + `${url}/sendMessage`, { chat_id: chatId, text: elem, parse_mode: 'Markdown' } + ) + } catch (err) { + this.logSendMessageError(err, type) + } + i++ + } + } else if (type === 'custom') { + const allowedMethods = [ + 'sendPhoto', + 'sendAudio', + 'sendDocument', + 'sendVideo', + 'sendVoice', + 'sendVideoNote', + 'sendMediaGroup', + 'sendLocation', + 'sendVenue', + 'sendContact', + 'sendChatAction', + ] + + for (const elem of body) { + const { method: customMethod, content } = elem + + if (!allowedMethods.includes(customMethod)) { + throw new BadRequestError(`Custom method ${customMethod} non-supported by Telegram`) + } + + try { + await agent.post(`${url}/${customMethod}`, { ...content, chat_id: chatId }) + } catch (err) { + this.logSendMessageError(err, type, customMethod) + } + } + } else { + try { + await agent.post(`${url}/${method}`, { + chat_id: chatId, + [type]: body, + reply_markup: { keyboard, one_time_keyboard: true }, + }) + } catch (err) { + this.logSendMessageError(err, type, method) + } + } + } + + /* + * Telegram specific helpers + */ + + logSendMessageError (err, type, method) { + if (method) { + logger.error(`[Telegram] Error sending message of type ${type} using method ${method}: ${err}`) + } else if (!method && type) { + logger.error(`[Telegram] Error sending message of type ${type}: ${err}`) + } else { + logger.error(`[Telegram] Error sending message: ${err})`) + } + logger.error( + `[Telegram] Error response: ${err.response.text}`, + '[Telegram] Error details', err.response.error + ) + } + + // Set a Telegram webhook + async setWebhook (token, webhook) { + const url = `https://api.telegram.org/bot${token}/setWebhook` + const { status } = await agent.post(url, { url: webhook }) + + if (status !== 200) { + throw new BadRequestError(`[Telegram][Status ${status}] Cannot set webhook`) + } + } + +} + +// These functions are exported only for tests purpose +export function tgFormatButton (button) { + const payload = { text: button.title } + if (button.type === 'web_url') { + payload.url = button.value + } else { + payload.callback_data = button.value + } + return payload +} + +const TG_MAX_KEYBOARD_LINES = 3 + +export function tgKeyboardLayout (buttons) { + const elemPerLine = Math.floor(buttons.length / TG_MAX_KEYBOARD_LINES) + const extraButtons = buttons.length % TG_MAX_KEYBOARD_LINES + const lines = [] + let seen = 0 + while (seen < buttons.length) { + const nb = elemPerLine + (extraButtons > lines.length ? 1 : 0) + lines.push(buttons.slice(seen, seen + nb)) + seen += nb + } + + return lines +} diff --git a/src/channel_integrations/telegram/index.js b/src/channel_integrations/telegram/index.js new file mode 100644 index 0000000..a37e105 --- /dev/null +++ b/src/channel_integrations/telegram/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['telegram'] } diff --git a/src/channel_integrations/telegram/test/channel.js b/src/channel_integrations/telegram/test/channel.js new file mode 100644 index 0000000..f655066 --- /dev/null +++ b/src/channel_integrations/telegram/test/channel.js @@ -0,0 +1,18 @@ +import { assert } from 'chai' +import Telegram from '../channel' +import mockups from './mockups.json' +const telegram = new Telegram() +const fakeFormatMessage = json => telegram.formatOutgoingMessage( + { channel: { token: '' }, chatId: '' }, + { attachment: json }, + { senderId: '' }, +) + +describe('Telegram service', () => { + mockups.forEach(mockup => + it('Should create keyboard layout from messages', () => { + const formattedMessage = fakeFormatMessage(mockup.json) + assert.deepEqual(JSON.parse(JSON.stringify(formattedMessage)), mockup.expected) + }) + ) +}) diff --git a/src/channel_integrations/telegram/test/integration.js b/src/channel_integrations/telegram/test/integration.js new file mode 100644 index 0000000..9be12d0 --- /dev/null +++ b/src/channel_integrations/telegram/test/integration.js @@ -0,0 +1,320 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' +import _ from 'lodash' + +/* eslint max-nested-callbacks: 0 */ // --> OFF + +const expect = chai.expect +const should = chai.should() + +const channelCreationParams = { + type: 'telegram', + slug: 'my-awesome-channel', + token: 'token', +} + +describe('Telegram channel', () => { + + const { createChannel, updateChannel, deleteChannel, + sendMessageToWebhook } = setupChannelIntegrationTests() + const telegramAPI = 'https://api.telegram.org' + + beforeEach(() => { + nock(telegramAPI).post('/bottoken/setWebhook').query(true).reply(200, {}) + }) + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = JSON.parse(JSON.stringify(channelCreationParams)) + newValues.token = 'newtoken' + nock(telegramAPI).post('/botnewtoken/setWebhook').query(true).reply(200, {}) + nock(telegramAPI).get('/bottoken/deleteWebhook').query(true).reply(200, {}) + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.token).to.equal(newValues.token) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + nock(telegramAPI).get('/bottoken/deleteWebhook').query(true).reply(200, {}) + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful with valid parameters', async () => { + const outgoingMessageCall = nock(telegramAPI).post('/bottoken/sendMessage').reply(200, {}) + const response = await sendMessageToWebhook(channel, { + message: { chat: { id: 123 }, text: 'a message' }, + }) + expect(response.status).to.equal(200) + expect(response.body).to.eql({ status: 'success' }) + expect(outgoingMessageCall.isDone()).to.be.true + }) + + describe('should be successful', () => { + + const body = { + message: { chat: { id: 123 }, text: 'a message' }, + } + const headers = {} + + it('in list format', async () => { + nock(telegramAPI).post('/bottoken/sendMessage').reply(200, {}) + const buttons = [ + { type: 'account_link', title: 'button title', value: 'https://link.com' }, + { type: 'web_url', title: 'button title', value: 'https://link.com' }, + { type: 'phone_number', title: 'button title', value: '0123554' }] + const listElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons, + } + const expectedBody = _.matches({ + chat_id: `${body.message.chat.id}`, + parse_mode: 'Markdown', + text: `*- ${listElement.title}*\n${listElement.subtitle}\n${listElement.imageUrl}`, + }) + const apiCall = nock(telegramAPI).post('/bottoken/sendMessage', expectedBody).reply(200, {}) + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'list', + content: { elements: [listElement], buttons }, + }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(apiCall.isDone()).to.be.true + }) + + it('in card format', async () => { + const cardElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const expectedPhotoBody = _.matches({ + chat_id: `${body.message.chat.id}`, + photo: cardElement.imageUrl, + }) + const photoRequest = nock(telegramAPI) + .post('/bottoken/sendPhoto', expectedPhotoBody).reply(200, {}) + const expectedMessageBody = _.matches({ + chat_id: `${body.message.chat.id}`, + parse_mode: 'Markdown', + reply_markup: { + keyboard: [[{ + text: cardElement.buttons[0].title, + callback_data: cardElement.buttons[0].value, + }]], + one_time_keyboard: true, + }, + text: `*${cardElement.title}*\n**${cardElement.subtitle}**`, + }) + const messageRequest = nock(telegramAPI) + .post('/bottoken/sendMessage', expectedMessageBody).reply(200, {}) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'card', content: cardElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(photoRequest.isDone()).to.be.true + expect(messageRequest.isDone()).to.be.true + }) + + it('in carousel format', async () => { + const carouselElement = { + title: 'title', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const expectedMessageBody = _.matches({ + chat_id: `${body.message.chat.id}`, + parse_mode: 'Markdown', + text: `*${carouselElement.title}*\n[](${carouselElement.imageUrl})`, + }) + const keyboardBody = _.matches({ + chat_id: `${body.message.chat.id}`, + parse_mode: 'Markdown', + reply_markup: { + keyboard: [[{ + text: carouselElement.buttons[0].title, + callback_data: carouselElement.buttons[0].value, + }]], + one_time_keyboard: true, + }, + text: `*${carouselElement.title}*\n[](${carouselElement.imageUrl})`, + }) + + const keyboardRequest = nock(telegramAPI) + .post('/bottoken/sendMessage', keyboardBody).reply(200, {}) + const messageRequest = nock(telegramAPI) + .post('/bottoken/sendMessage', expectedMessageBody).reply(200, {}) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'carousel', content: [carouselElement] }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(messageRequest.isDone()).to.be.true + expect(keyboardRequest.isDone()).to.be.true + }) + + it('in quickReplies format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const expectedMessageBody = _.matches({ + chat_id: `${body.message.chat.id}`, + parse_mode: 'Markdown', + reply_markup: { + keyboard: [[{ + text: buttonsElement.buttons[0].title, + callback_data: buttonsElement.buttons[0].value, + }]], + one_time_keyboard: true, + }, + text: `*${buttonsElement.title}*\n****`, + }) + const messageRequest = nock(telegramAPI) + .post('/bottoken/sendMessage', expectedMessageBody).reply(200, {}) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'quickReplies', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(messageRequest.isDone()).to.be.true + }) + + it('in video format', async () => { + const videoLink = 'https://link.com' + const expectedVideoBody = _.matches({ + chat_id: `${body.message.chat.id}`, + reply_markup: { one_time_keyboard: true }, + video: videoLink, + }) + const videoRequest = nock(telegramAPI) + .post('/bottoken/sendVideo', expectedVideoBody).reply(200, {}) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'video', content: videoLink }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(videoRequest.isDone()).to.be.true + }) + + it('in picture format', async () => { + const pictureLink = 'https://link.com' + const expectedPictureBody = _.matches({ + chat_id: `${body.message.chat.id}`, + reply_markup: { one_time_keyboard: true }, + photo: pictureLink, + }) + const pictureRequest = nock(telegramAPI) + .post('/bottoken/sendPhoto', expectedPictureBody).reply(200, {}) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'picture', content: pictureLink }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(pictureRequest.isDone()).to.be.true + }) + + it('in picture format - failing', async () => { + const pictureLink = 'https://link.com' + const expectedPictureBody = _.matches({ + chat_id: `${body.message.chat.id}`, + reply_markup: { one_time_keyboard: true }, + photo: pictureLink, + }) + const pictureRequest = nock(telegramAPI) + .post('/bottoken/sendPhoto', expectedPictureBody) + .reply(403, { + ok: false, + error_code: 403, + description: 'Forbidden: bot was blocked by the user', + }) + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'picture', content: pictureLink }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(pictureRequest.isDone()).to.be.true + }) + + it('in buttons format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'buttons', content: buttonsElement }]), + } + const expectedMessageBody = _.matches({ + chat_id: `${body.message.chat.id}`, + reply_markup: { + keyboard: [[{ + text: buttonsElement.buttons[0].title, + callback_data: buttonsElement.buttons[0].value, + }]], + one_time_keyboard: true, + }, + text: buttonsElement.title, + }) + const messageRequest = nock(telegramAPI) + .post('/bottoken/sendMessage', expectedMessageBody).reply(200, {}) + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + expect(messageRequest.isDone()).to.be.true + }) + }) + + }) +}) diff --git a/src/channel_integrations/telegram/test/mockups.json b/src/channel_integrations/telegram/test/mockups.json new file mode 100644 index 0000000..974e8c6 --- /dev/null +++ b/src/channel_integrations/telegram/test/mockups.json @@ -0,0 +1,205 @@ +[ + { + "json": { + "type": "quickReplies", + "content": { + "title": "Quick reply title", + "buttons": [ + { + "value": "quick reply payload1", + "title": "quick reply text1" + }, + { + "value": "quick reply payload2", + "title": "quick reply text2" + } + ] + } + }, + "expected": { + "chatId": "", + "type": "card", + "to": "", + "token": "", + "body": "*Quick reply title*\n****", + "keyboard": [ + [ + { + "text": "quick reply text1", + "callback_data": "quick reply payload1" + } + ], + [ + { + "text": "quick reply text2", + "callback_data": "quick reply payload2" + } + ] + ] + } + }, + { + "json": { + "type": "quickReplies", + "content": { + "title": "Quick reply title", + "buttons": [ + { + "title": "t1", + "value": "v1" + }, + { + "title": "t2", + "value": "v2" + }, + { + "title": "t3", + "value": "v3" + }, + { + "title": "t4", + "value": "v4" + }, + { + "title": "t5", + "value": "v5" + }, + { + "title": "t6", + "value": "v6" + }, + { + "title": "t7", + "value": "v7" + }, + { + "title": "t8", + "value": "v8" + } + ] + } + }, + "expected": { + "chatId": "", + "type": "card", + "to": "", + "token": "", + "body": "*Quick reply title*\n****", + "keyboard": [ + [ + { + "text": "t1", + "callback_data": "v1" + }, + { + "text": "t2", + "callback_data": "v2" + }, + { + "text": "t3", + "callback_data": "v3" + } + ], + [ + { + "text": "t4", + "callback_data": "v4" + }, + { + "text": "t5", + "callback_data": "v5" + }, + { + "text": "t6", + "callback_data": "v6" + } + ], + [ + { + "text": "t7", + "callback_data": "v7" + }, + { + "text": "t8", + "callback_data": "v8" + } + ] + ] + } + }, + { + "json": { + "type": "carousel", + "content": [ + { + "title": "Card 1 title", + "subtitle": "Card 1 subtitle", + "imageUrl": "image1", + "buttons": [ + { + "title": "t1", + "value": "v1", + "type": "postback" + }, + { + "title": "t2", + "value": "v2", + "type": "web_url" + } + ] + }, + { + "title": "Card 2 title", + "subtitle": "Card 2 subtitle", + "imageUrl": "image2", + "buttons": [ + { + "title": "t3", + "value": "v3", + "type": "postback" + }, + { + "title": "t4", + "value": "v4", + "type": "postback" + } + ] + } + ] + }, + "expected": { + "body": [ + "*Card 1 title*\n[Card 1 subtitle](image1)", + "*Card 2 title*\n[Card 2 subtitle](image2)" + ], + "chatId": "", + "keyboard": [ + [ + { + "callback_data": "v1", + "text": "t1" + }, + { + "text": "t2", + "url": "v2" + } + ], + [ + { + "callback_data": "v3", + "text": "t3" + } + ], + [ + { + "callback_data": "v4", + "text": "t4" + } + ] + ], + "to": "", + "token": "", + "type": "carousel" + } + } +] \ No newline at end of file diff --git a/src/channel_integrations/twilio/channel.js b/src/channel_integrations/twilio/channel.js new file mode 100644 index 0000000..e431087 --- /dev/null +++ b/src/channel_integrations/twilio/channel.js @@ -0,0 +1,88 @@ +import _ from 'lodash' +import crypto from 'crypto' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import AbstractChannelIntegration from '../abstract_channel_integration' +import { BadRequestError, ForbiddenError, textFormatMessage } from '../../utils' + +const agent = superagentPromise(superagent, Promise) + +export default class Twilio extends AbstractChannelIntegration { + + validateChannelObject (channel) { + channel.phoneNumber = channel.phoneNumber.split(' ').join('') + + if (!channel.clientId) { + throw new BadRequestError('Parameter is missing: Client Id') + } else if (!channel.clientSecret) { + throw new BadRequestError('Parameter is missing: Client Secret') + } else if (!channel.serviceId) { + throw new BadRequestError('Parameter is missing: Service Id') + } else if (!channel.phoneNumber) { + throw new BadRequestError('Parameter is missing: Phone Number') + } + } + + authenticateWebhookRequest (req, res, channel) { + const signature = req.headers['x-twilio-signature'] + const webhook = channel.webhook + let str = webhook + _.forOwn(_.sortBy(Object.keys(req.body)), (value) => { + str += value + str += req.body[value] + }) + const hmac = crypto.createHmac('SHA1', channel.clientSecret).update(str).digest('base64') + if (signature !== hmac) { + throw new ForbiddenError() + } + } + + populateMessageContext (req) { + const { body } = req + + return { + chatId: `${body.To}${body.From}`, + senderId: body.From, + } + } + + parseIncomingMessage (conversation, message) { + return { + attachment: { + type: 'text', + content: message.Body, + }, + } + } + + formatOutgoingMessage (conversation, message, opts) { + const { chatId } = conversation + const to = opts.senderId + + let msg + try { + msg = textFormatMessage(message) + } catch (error) { + throw new BadRequestError('Message type non-supported by Twilio') + } + + return { chatId, to, ...msg } + } + + async sendMessage (conversation, message) { + const data = { + To: message.to, + Body: message.body, + From: conversation.channel.phoneNumber, + MessagingServiceSid: conversation.channel.serviceId, + } + const url + = `https://api.twilio.com/2010-04-01/Accounts/${conversation.channel.clientId}/Messages.json` + await agent('POST', url) + .auth(conversation.channel.clientId, conversation.channel.clientSecret) + .type('form') + .send(data) + } + +} diff --git a/src/channel_integrations/twilio/index.js b/src/channel_integrations/twilio/index.js new file mode 100644 index 0000000..17354e1 --- /dev/null +++ b/src/channel_integrations/twilio/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['twilio'] } diff --git a/src/channel_integrations/twilio/test/integration.js b/src/channel_integrations/twilio/test/integration.js new file mode 100644 index 0000000..395712e --- /dev/null +++ b/src/channel_integrations/twilio/test/integration.js @@ -0,0 +1,137 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import crypto from 'crypto' +import _ from 'lodash' +import nock from 'nock' + +const expect = chai.expect +const should = chai.should() + +const channelCreationParams = { + type: 'twilio', + slug: 'my-awesome-channel', + clientId: 'client-id', + clientSecret: 'client-secret', + serviceId: 'service-id', + phoneNumber: 'phone-number', +} + +describe('Twilio channel', () => { + + const { createChannel, updateChannel, + deleteChannel, sendMessageToWebhook } = setupChannelIntegrationTests() + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = JSON.parse(JSON.stringify(channelCreationParams)) + newValues.clientId = 'newclientId' + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.clientId).to.equal(newValues.clientId) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful with valid token', async () => { + const outgoingMessageCall = nock('https://api.twilio.com') + .post('/2010-04-01/Accounts/client-id/Messages.json').reply(200, {}) + const message = { To: 'recipient', From: 'sender', Body: 'Text' } + let signature = channel.webhook + /* eslint max-nested-callbacks: ["error", 4]*/ + _.forOwn(_.sortBy(Object.keys(message)), (key) => { + signature += key + signature += message[key] + }) + const hmac = crypto.createHmac('SHA1', channel.clientSecret) + .update(signature) + .digest('base64') + const headers = { 'x-twilio-signature': hmac } + const response = await sendMessageToWebhook(channel, message, headers) + expect(response.status).to.equal(200) + expect(outgoingMessageCall.isDone()).to.be.true + }) + + it('should not send a response for an empty incoming message', async () => { + const outgoingMessageCall = nock('https://api.twilio.com') + .post('/2010-04-01/Accounts/client-id/Messages.json').reply(200, {}) + const message = { To: 'recipient', From: 'sender' } + let signature = channel.webhook + /* eslint max-nested-callbacks: ["error", 4]*/ + _.forOwn(_.sortBy(Object.keys(message)), (key) => { + signature += key + signature += message[key] + }) + const hmac = crypto.createHmac('SHA1', channel.clientSecret) + .update(signature) + .digest('base64') + const headers = { 'x-twilio-signature': hmac } + const response = await sendMessageToWebhook(channel, message, headers) + expect(response.status).to.equal(200) + expect(outgoingMessageCall.isDone()).to.be.false + }) + + it('should return 401 with invalid token', async () => { + try { + const headers = { 'x-twilio-signature': 'invalid' } + const message = { To: 'recipient', From: 'sender' } + await sendMessageToWebhook(channel, message, headers) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + it('should return 401 without a token', async () => { + try { + const message = { To: 'recipient', From: 'sender' } + await sendMessageToWebhook(channel, message) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + } + }) + + }) +}) diff --git a/src/services/Twitter.service.js b/src/channel_integrations/twitter/channel.js similarity index 58% rename from src/services/Twitter.service.js rename to src/channel_integrations/twitter/channel.js index 595a514..ed409dc 100644 --- a/src/services/Twitter.service.js +++ b/src/channel_integrations/twitter/channel.js @@ -1,37 +1,21 @@ import _ from 'lodash' -import Template from './Template.service' -import { Logger, getTwitterWebhookToken, deleteTwitterWebhook, postMediaToTwitterFromUrl } from '../utils' -import { BadRequestError, ForbiddenError, StopPipeline } from '../utils/errors' +import AbstractChannelIntegration from '../abstract_channel_integration' +import { logger, getTwitterWebhookToken, + deleteTwitterWebhook, postMediaToTwitterFromUrl } from '../../utils' +import { BadRequestError, ForbiddenError, StopPipeline } from '../../utils/errors' import Twit from 'twit' import { URL } from 'url' -/* - * checkParamsValidity: ok - * onChannelCreate: ok - * onChannelUpdate: ok - * onChannelDelete: ok - * onWebhookChecking: ok - * checkSecurity: ok - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - const VIDEO_AS_LINK_HOSTS = [ 'youtube.com', 'youtu.be', ] -export default class Twitter extends Template { +export default class Twitter extends AbstractChannelIntegration { - static checkParamsValidity (channel) { - const params = ['consumerKey', 'consumerSecret', 'accessToken', 'accessTokenSecret'] + validateChannelObject (channel) { + const params = ['consumerKey', 'consumerSecret', 'accessToken', 'accessTokenSecret', 'envName'] params.forEach(param => { if (!channel[param] || typeof channel[param] !== 'string') { throw new BadRequestError('Bad parameter '.concat(param).concat(' : missing or not string')) @@ -39,63 +23,58 @@ export default class Twitter extends Template { }) } - static async onChannelCreate (channel) { + async afterChannelCreated (channel) { const T = new Twit({ consumer_key: channel.consumerKey, consumer_secret: channel.consumerSecret, access_token: channel.accessToken, access_token_secret: channel.accessTokenSecret, + app_only_auth: true, timeout_ms: 60 * 1000, }) try { // Get the previously set webhook - const res = await T.get('account_activity/webhooks', {}) + const res = await T.get(`account_activity/all/${channel.envName}/webhooks`, {}) if (!res.data || res.data.length === 0) { throw new Error() } - // Try to delete the webhook if there's one - await new Promise((resolve, reject) => { - T._buildReqOpts('DELETE', `account_activity/webhooks/${res.data[0].id}`, {}, false, (err, reqOpts) => { - if (err) { return reject(err) } - - T._doRestApiRequest(reqOpts, {}, 'DELETE', (err, parsedBody) => { - if (err) { return reject(err) } - return resolve(parsedBody) - }) - }) - }) + await deleteTwitterWebhook(T, res.data[0].id, channel.envName) } catch (err) { - Logger.info('[Twitter] unable to get and delete previously set webhook') + logger.error(`[Twitter] Unable to get and delete previously set webhook: ${err}`) } try { - const res = await T.post('account_activity/webhooks', { url: channel.webhook }) + T.config.app_only_auth = false + const res = await T.post(`account_activity/all/${channel.envName}/webhooks`, + { url: channel.webhook }) const ret = res.data - channel.isErrored = ret.valid !== true || ret.url !== channel.webhook || !ret.id - if (!channel.isErrored) { - channel.webhookToken = ret.id - await T.post('account_activity/webhooks/'.concat(channel.webhookToken).concat('/subscriptions'), {}) - const account = await T.get('account/verify_credentials', {}) - channel.clientId = account.data.id_str + channel.isErrored = ret.valid !== true || !ret.url || !ret.id + if (channel.isErrored) { + throw new Error() } + + await T + .post(`account_activity/all/${channel.envName}/subscriptions`, {}) + const account = await T.get('account/verify_credentials', {}) + channel.clientId = account.data.id_str } catch (err) { - Logger.inspect('[Twitter] unable to set the webhook') + logger.error(`[Twitter] Unable to set the webhook: ${err}`) channel.isErrored = true } return channel.save() } - static async onChannelUpdate (channel, oldChannel) { - await Twitter.onChannelDelete(oldChannel) - await Twitter.onChannelCreate(channel) + async afterChannelUpdated (channel, oldChannel) { + await this.afterChannelDeleted(oldChannel) + await this.beforeChannelCreated(channel) } - static async onChannelDelete (channel) { + async afterChannelDeleted (channel) { const T = new Twit({ consumer_key: channel.consumerKey, consumer_secret: channel.consumerSecret, @@ -107,11 +86,11 @@ export default class Twitter extends Template { try { await deleteTwitterWebhook(T, channel.webhookToken) } catch (err) { - Logger.info('[Twitter] Error while unsetting webhook : ', err) + logger.error(`[Twitter] Error while unsetting webhook: ${err}`) } } - static onWebhookChecking (req, res, channel) { + validateWebhookSubscriptionRequest (req, res, channel) { if (!channel.consumerSecret) { throw new BadRequestError('Error while checking webhook validity : no channel.consumerSecret') } @@ -121,20 +100,21 @@ export default class Twitter extends Template { res.status(200).json({ response_token: sha }) } - static checkSecurity (req, res, channel) { - const hash = req.headers['X-Twitter-Webhooks-Signature'] || req.headers['x-twitter-webhooks-signature'] || '' + authenticateWebhookRequest (req, res, channel) { + const hash = req.headers['X-Twitter-Webhooks-Signature'] + || req.headers['x-twitter-webhooks-signature'] || '' const test = getTwitterWebhookToken(channel.consumerSecret, req.rawBody) - if (hash.startsWith('sha256=') && hash === test) { - res.status(200).send() - } else { + if (!hash.startsWith('sha256=') || hash !== test) { throw new ForbiddenError('Invalid Twitter signature') } } - static extractOptions (req) { - const recipientId = _.get(req, 'body.direct_message_events[0].message_create.target.recipient_id') - const senderId = _.get(req, 'body.direct_message_events[0].message_create.sender_id') + populateMessageContext (req) { + const recipientId + = _.get(req, 'body.direct_message_events[0].message_create.target.recipient_id') + const senderId + = _.get(req, 'body.direct_message_events[0].message_create.sender_id') return { chatId: `${recipientId}-${senderId}`, @@ -142,23 +122,27 @@ export default class Twitter extends Template { } } - static async parseChannelMessage (conversation, message, opts) { + parseIncomingMessage (conversation, message, opts) { message = _.get(message, 'direct_message_events[0]') const channel = conversation.channel const senderId = _.get(message, 'message_create.sender_id') const recipientId = _.get(message, 'message_create.target.recipient_id') + const data = _.get(message, 'message_create.message_data') + const quickReply = _.get(message, 'message_create.message_data.quick_reply_response.metadata') // can be an echo message - if (senderId !== opts.senderId || senderId === channel.clientId || recipientId !== channel.clientId) { - throw new StopPipeline() - } - const data = _.get(message, 'message_create.message_data') - if (!data) { + if (senderId !== opts.senderId + || senderId === channel.clientId + || recipientId !== channel.clientId + || !data) { throw new StopPipeline() } + const msg = {} const hasMedia = (_.get(data, 'attachment.type') === 'media') - if (!hasMedia) { + if (quickReply) { + msg.attachment = { type: 'text', content: quickReply, is_button_click: true } + } else if (!hasMedia) { msg.attachment = { type: 'text', content: _.get(data, 'text') } } else { const media = _.get(data, 'attachment.media') @@ -172,10 +156,10 @@ export default class Twitter extends Template { } msg.attachment = { type, content: media.media_url } } - return Promise.all([conversation, msg, { ...opts, mentioned: true }]) + return msg } - static async formatMessage (conversation, message, opts) { + async formatOutgoingMessage (conversation, message, opts) { const { type, content } = _.get(message, 'attachment') let data = [{ text: '' }] @@ -207,9 +191,9 @@ export default class Twitter extends Template { } } else if (type === 'quickReplies') { data[0].text = content.title - const options = content.buttons + const context = content.buttons .map(({ title, value }) => ({ label: title, metadata: value })) - data[0].quick_reply = { type: 'options', options } + data[0].quick_reply = { type: 'options', options: context } } else if (type === 'card') { const { title, subtitle, imageUrl, buttons } = content const mediaId = await postMediaToTwitterFromUrl(conversation.channel, imageUrl) @@ -218,9 +202,9 @@ export default class Twitter extends Template { data.push({}) data[1] = { text: subtitle.length > 0 ? subtitle : '.' } - const options = buttons + const context = buttons .map(({ title, value }) => ({ label: title, metadata: value })) - data[1].quick_reply = { type: 'options', options } + data[1].quick_reply = { type: 'options', options: context } } else if (type === 'carousel' || type === 'carouselle') { data = await Promise.all(content .map(makeListElement)) @@ -228,10 +212,16 @@ export default class Twitter extends Template { data = await Promise.all(content.elements .map(makeListElement)) if (content.buttons) { - const options = content.buttons + const context = content.buttons .map(({ title, value }) => ({ label: title, metadata: value })) - data.push({ text: '_', quick_reply: { type: 'options', options } }) + data.push({ text: '_', quick_reply: { type: 'options', options: context } }) } + } else if (type === 'buttons') { + data[0].text = content.title + data[0].ctas = content.buttons + .map(({ title, value }) => ({ type: 'web_url', label: title, url: value })) + } else if (type === 'custom') { + data = content } else { throw new BadRequestError('Message type non-supported by Twitter : '.concat(type)) } @@ -251,7 +241,7 @@ export default class Twitter extends Template { return data.map(makeMessage) } - static async sendMessage (conversation, message) { + async sendMessage (conversation, message) { const channel = conversation.channel const T = new Twit({ consumer_key: channel.consumerKey, @@ -264,4 +254,44 @@ export default class Twitter extends Template { await T.post('direct_messages/events/new', message) } + /* + * Gromit methods + */ + + populateParticipantData (participant, channel) { + return new Promise((resolve, reject) => { + const T = new Twit({ + consumer_key: channel.consumerKey, + consumer_secret: channel.consumerSecret, + app_only_auth: true, + timeout_ms: 60 * 1000, + }) + + T.get('users/lookup', { user_id: participant.senderId }, (err, data) => { + if (err) { + logger.error(`[Twitter] Error when retrieving Twitter user info: ${err}`) + return reject(err) + } + if (data.length <= 0) { + const msg = '[Twitter] Error when retrieving Twitter user info: no data' + logger.error(msg) + return reject(new Error(msg)) + } + participant.data = data[0] + participant.markModified('data') + participant.save().then(resolve).catch(reject) + }) + }) + } + + parseParticipantDisplayName (participant) { + const informations = {} + + // could get quite a lot more information + if (participant.data) { + informations.userName = participant.data.name + } + + return informations + } } diff --git a/src/channel_integrations/twitter/index.js b/src/channel_integrations/twitter/index.js new file mode 100644 index 0000000..34f6c79 --- /dev/null +++ b/src/channel_integrations/twitter/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['twitter'] } diff --git a/src/channel_integrations/twitter/test/integration.js b/src/channel_integrations/twitter/test/integration.js new file mode 100644 index 0000000..96504bb --- /dev/null +++ b/src/channel_integrations/twitter/test/integration.js @@ -0,0 +1,245 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' +import nock from 'nock' +import { Channel, Connector } from '../../../models' +import _ from 'lodash' + +/* eslint max-nested-callbacks: 0 */ // --> OFF + +const expect = chai.expect +const should = chai.should() +const channelCreationParams = { + type: 'twitter', + slug: 'my-awesome-channel', + consumerKey: 'consumerkey', + consumerSecret: 'consumersecret', + accessToken: 'accesstoken', + accessTokenSecret: 'accesstokensecret', +} + +describe('Twitter channel', () => { + + // Need to increase timeout, because channel creation contains hard-coded 4s delay + const creationTimeout = 10000 + const { createChannel, updateChannel, + deleteChannel, sendMessageToWebhook } = setupChannelIntegrationTests(false) + const twitterAPI = 'https://api.twitter.com' + + let channel + // Only create channel once to avoid long test runs due to hard-coded 4s delay in creation call + before(function (done) { + // Mock Twitter API calls + const webhook = { valid: true, id: 123, url: 'webhook' } + nock(twitterAPI).get('/1.1/account_activity/webhooks.json').query(true).reply(200, [{ id: 0 }]) + nock(twitterAPI).delete('/1.1/account_activity/webhooks/0.json').query(true).reply(200, {}) + nock(twitterAPI).post('/1.1/account_activity/webhooks.json').query(true).reply(200, webhook) + nock(twitterAPI).post('/1.1/account_activity/webhooks/123/subscriptions.json') + .query(true).reply(200, {}) + nock(twitterAPI).get('/1.1/account/verify_credentials.json').query(true).reply(200, { + data: { id_str: 'client-id' }, + }) + this.timeout(creationTimeout) + createChannel(channelCreationParams).then((result) => { + channel = result.body.results + done() + }) + }) + + after(async () => { + await Connector.remove() + await Channel.remove() + }) + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + expect(channel.type).to.equal(channelCreationParams.type) + expect(channel.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(channel.isErrored).to.be.false + expect(channel.isActivated).to.be.true + }) + it('should return 400 with invalid parameters', async () => { + try { + await createChannel({}) + should.fail() + } catch (error) { + expect(error.status).to.equal(400) + } + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + const newValues = JSON.parse(JSON.stringify(channelCreationParams)) + newValues.accessToken = 'newtoken' + nock(twitterAPI).delete('/1.1/account_activity/webhooks/123.json').reply(200, {}) + const response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.accessToken).to.equal(newValues.accessToken) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + const deletableChannel = _.cloneDeep(channelCreationParams) + deletableChannel.slug = 'to-be-deleted' + let response = await createChannel(deletableChannel) + const deleteMe = response.body.results + nock(twitterAPI).delete('/1.1/account_activity/webhooks/undefined.json').reply(200, {}) + nock(twitterAPI).post('/1.1/account_activity/webhooks/').reply(200, {}) + response = await deleteChannel(deleteMe) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + it('should return 401 with invalid token', async () => { + try { + await sendMessageToWebhook(channel, {}, { 'X-Twitter-Webhooks-Signature': 'invalid-token' }) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + expect(error.response.body.message).to.equal('Invalid Twitter signature') + } + }) + + it('should return 401 without token', async () => { + try { + await sendMessageToWebhook(channel, {}) + should.fail() + } catch (error) { + expect(error.status).to.equal(401) + expect(error.response.body.message).to.equal('Invalid Twitter signature') + } + }) + + describe('should be successful', () => { + + beforeEach(() => { + nock(twitterAPI).get('/1.1/users/lookup.json').query(true).reply(200, {}) + }) + + const body = { + direct_message_events: [ + { + message_create: { + sender_id: 456, + target: { + // recipient_id: channelCreationParams.clientId, + }, + message_data: { + text: 'a message', + }, + }, + }, + ], + } + const signature = 'sha256=9a0dmwmJem4AvLj2aKpSqcvWv9SAVGH0BWZkAPNsMjg=' + const headers = { 'X-Twitter-Webhooks-Signature': signature } + + it('in text format', async () => { + const response = await sendMessageToWebhook(channel, body, headers) + expect(response.status).to.equal(200) + }) + + // The twitter integration wants to upload attached image files to twitter + // Not sure how to mock this, so skipping those test cases for now + + it.skip('in list format', async () => { + const buttons = [ + { type: 'account_link', title: 'button title', value: 'https://link.com' }, + { type: 'web_url', title: 'button title', value: 'https://link.com' }, + { type: 'phone_number', title: 'button title', value: '0123554' }] + const listElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://image.png', + buttons, + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ + type: 'list', + content: { elements: [listElement], buttons }, + }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it.skip('in card format', async () => { + const cardElement = { + title: 'title', + subtitle: 'subtitle', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'card', content: cardElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it.skip('in carousel format', async () => { + const carouselElement = { + title: 'title', + imageUrl: 'https://img.url', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'carousel', content: [carouselElement] }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in quickReplies format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'quickReplies', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it.skip('in video format', async () => { + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'video', content: 'https://link.com' }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it.skip('in picture format', async () => { + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'picture', content: 'https://link.com' }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + + it('in buttons format', async () => { + const buttonsElement = { + title: 'title', + buttons: [{ type: '', title: 'button title', value: 'abc' }], + } + const botResponse = { + results: {}, + messages: JSON.stringify([{ type: 'buttons', content: buttonsElement }]), + } + const response = await sendMessageToWebhook(channel, body, headers, botResponse) + expect(response.status).to.equal(200) + }) + }) + }) +}) diff --git a/src/channel_integrations/webchat/channel.js b/src/channel_integrations/webchat/channel.js new file mode 100644 index 0000000..8803773 --- /dev/null +++ b/src/channel_integrations/webchat/channel.js @@ -0,0 +1,45 @@ +import AbstractChannelIntegration from '../abstract_channel_integration' +import { getWebhookToken, sendToWatchers } from '../../utils' +import { Participant } from '../../models' +export default class Webchat extends AbstractChannelIntegration { + + async beforeChannelCreated (channel) { + channel.token = getWebhookToken(channel._id, channel.slug) + return channel.save() + } + + populateMessageContext (req) { + const { chatId } = req.body + + return { + chatId, + senderId: `p-${chatId}`, + } + } + + formatOutgoingMessage (conversation, message) { + return message + } + + parseIncomingMessage (conversation, body) { + const { attachment } = body.message + + if (attachment.type === 'button' || attachment.type === 'quickReply') { + attachment.type = 'text' + attachment.title = attachment.content.title + attachment.content = attachment.content.value + } + + return { attachment } + } + + getMemoryOptions (body) { + return body.memoryOptions || { memory: {}, merge: true } + } + + async sendMessage (conversation, message) { + message.participant = await Participant.findById(message.participant) + await sendToWatchers(conversation._id, [message.serialize]) + } + +} diff --git a/src/channel_integrations/webchat/index.js b/src/channel_integrations/webchat/index.js new file mode 100644 index 0000000..2864877 --- /dev/null +++ b/src/channel_integrations/webchat/index.js @@ -0,0 +1,3 @@ +import channel from './channel' + +module.exports = { channel, identifiers: ['webchat', 'recastwebchat'] } diff --git a/src/channel_integrations/webchat/test/integration.js b/src/channel_integrations/webchat/test/integration.js new file mode 100644 index 0000000..a89cbf4 --- /dev/null +++ b/src/channel_integrations/webchat/test/integration.js @@ -0,0 +1,68 @@ +import chai from 'chai' +import { setupChannelIntegrationTests } from '../../../../test/tools' + +const expect = chai.expect +const channelCreationParams = { + type: 'webchat', + slug: 'my-awesome-channel', +} + +describe('Webchat channel', () => { + + const { createChannel, deleteChannel, updateChannel, + sendMessageToWebhook } = setupChannelIntegrationTests() + + describe('Creation', () => { + it('should be successful with valid parameters', async () => { + const response = await createChannel(channelCreationParams) + const { results: result, message } = response.body + + expect(response.status).to.equal(201) + expect(message).to.equal('Channel successfully created') + expect(result.type).to.equal(channelCreationParams.type) + expect(result.slug).to.equal(channelCreationParams.slug) + /* eslint no-unused-expressions: 0 */ // --> OFF + expect(result.isErrored).to.be.false + expect(result.isActivated).to.be.true + }) + }) + + describe('Update', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + const newValues = Object.assign({}, channelCreationParams) + newValues.token = 'newtoken' + response = await updateChannel(channel, newValues) + expect(response.status).to.equal(200) + expect(response.body.results.token).to.equal(newValues.token) + }) + }) + + describe('Deletion', () => { + it('should be successful if channel exists', async () => { + let response = await createChannel(channelCreationParams) + const channel = response.body.results + response = await deleteChannel(channel) + expect(response.status).to.equal(200) + }) + }) + + describe('sending a message', () => { + + let channel + + beforeEach(async () => { + channel = (await createChannel(channelCreationParams)).body.results + }) + + it('should be successful with valid parameters', async () => { + const response = await sendMessageToWebhook(channel, { + chatId: 123, + message: { attachment: { type: 'text', content: { value: 'a message' } } }, + }) + expect(response.status).to.equal(200) + }) + + }) +}) diff --git a/src/constants/channels.js b/src/constants/channels.js new file mode 100644 index 0000000..16d93c1 --- /dev/null +++ b/src/constants/channels.js @@ -0,0 +1,19 @@ +export const permitted = '{type,slug,isActivated,token,userName,apiKey,webhook,' + + 'clientId,clientSecret,botuser,password,phoneNumber,serviceId,consumerKey,' + + 'consumerSecret,accessToken,accessTokenSecret,envName,clientAppId,accentColor,' + + 'complementaryColor,botMessageColor,botMessageBackgroundColor,' + + 'backgroundColor,headerLogo,headerTitle,botPicture,userPicture,' + + 'onboardingMessage,userInputPlaceholder,expanderLogo,' + + 'expanderTitle,conversationTimeToLive,characterLimit,' + + 'characterLimit,openingType,welcomeMessage,refreshToken,' + + 'oAuthCode,invocationName,webchatLocale}' + +export const permittedUpdate = '{slug,isActivated,token,userName,apiKey,webhook,' + + 'clientId,clientSecret,botuser,password,phoneNumber,serviceId,consumerKey,' + + 'consumerSecret,accessToken,accessTokenSecret,clientAppId,accentColor,' + + 'complementaryColor,botMessageColor,botMessageBackgroundColor,' + + 'backgroundColor,headerLogo,headerTitle,botPicture,userPicture,' + + 'onboardingMessage,userInputPlaceholder,expanderLogo,expanderTitle,' + + 'conversationTimeToLive,characterLimit,' + + 'openingType,welcomeMessage,refreshToken,' + + 'invocationName,botEnvironmentId,webchatLocale}' diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 0000000..9ca5413 --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1 @@ +export ChannelsConstants from './Channels.constants' diff --git a/src/controllers/Channels.controller.js b/src/controllers/Channels.controller.js deleted file mode 100644 index 49a544c..0000000 --- a/src/controllers/Channels.controller.js +++ /dev/null @@ -1,130 +0,0 @@ -import _ from 'lodash' -import filter from 'filter-object' - -import { invoke } from '../utils' -import { NotFoundError, ConflictError } from '../utils/errors' -import { renderOk, renderCreated, renderDeleted } from '../utils/responses' - -const permitted = '{type,slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,botuser,password,phoneNumber,serviceId,consumerKey,consumerSecret,accessToken,accessTokenSecret}' -const permittedUpdate = '{slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,botuser,password,phoneNumber,serviceId,consumerKey,consumerSecret,accessToken,accessTokenSecret}' - -export default class ChannelsController { - - /** - * Create a new channel - */ - static async create (req, res) { - const { connector_id } = req.params - const params = filter(req.body, permitted) - const slug = params.slug - - const connector = await models.Connector.findById(connector_id) - .populate('channels') - - if (!connector) { - throw new NotFoundError('Connector') - } else if (connector.channels.find(c => c.slug === slug)) { - throw new ConflictError('Channel slug is already taken') - } - - const channel = await global.models.Channel({ ...params, connector: connector._id }) - channel.webhook = `${global.config.base_url}/webhook/${channel._id}` - connector.channels.push(channel) - - await Promise.all([ - connector.save(), - channel.save(), - ]) - - await invoke(channel.type, 'onChannelCreate', [channel]) - - return renderCreated(res, { - results: channel.serialize, - message: 'Channel successfully created', - }) - } - - /** - * Index channels - */ - static async index (req, res) { - const { connector_id } = req.params - - const connector = await models.Connector.findById(connector_id) - .populate('channels') - - if (!connector) { - throw new NotFoundError('Connector') - } - - return renderOk(res, { - results: connector.channels.map(c => c.serialize), - message: connector.channels.length ? 'Channels successfully rendered' : 'No channels', - }) - } - - /** - * Show a channel - */ - static async show (req, res) { - const { connector_id, channel_slug } = req.params - - const channel = await models.Channel.findOne({ slug: channel_slug, connector: connector_id }) - .populate('children') - - if (!channel) { - throw new NotFoundError('Channel') - } - - return renderOk(res, { - results: channel.serialize, - message: 'Channel successfully rendered', - }) - } - - /** - * Update a channel - */ - static async update (req, res) { - const { connector_id, channel_slug } = req.params - - const oldChannel = await global.models.Channel.findOne({ slug: channel_slug, connector: connector_id }) - const channel = await global.models.Channel.findOneAndUpdate( - { slug: channel_slug, connector: connector_id }, - { $set: filter(req.body, permittedUpdate) }, - { new: true } - ) - - if (!channel || !oldChannel) { - throw new NotFoundError('Channel') - } - - await invoke(channel.type, 'onChannelUpdate', [channel, oldChannel]) - - return renderOk(res, { - results: channel.serialize, - message: 'Channel successfully updated', - }) - } - - /** - * Delete a channel - */ - static async delete (req, res) { - const { connector_id, channel_slug } = req.params - - const channel = await models.Channel.findOne({ connector: connector_id, slug: channel_slug }) - - if (!channel) { - throw new NotFoundError('Channel') - } - - await Promise.all([ - channel.delete(), - invoke(channel.type, 'onChannelDelete', [channel]), - ]) - - return renderDeleted(res, 'Channel successfully deleted') - } - -} diff --git a/src/controllers/Connectors.controller.js b/src/controllers/Connectors.controller.js deleted file mode 100644 index dd604ea..0000000 --- a/src/controllers/Connectors.controller.js +++ /dev/null @@ -1,83 +0,0 @@ -import _ from 'lodash' -import filter from 'filter-object' - -import { NotFoundError, BadRequestError } from '../utils/errors' -import { renderOk, renderCreated, renderDeleted } from '../utils/responses' - -const permittedAdd = '{url,isTyping}' -const permittedUpdate = '{url,isTyping}' - -export default class ConnectorsController { - - /** - * Create a new connector - */ - static async create (req, res) { - const payload = filter(req.body, permittedAdd) - - const connector = await new models.Connector(payload).save() - - return renderCreated(res, { - results: connector.serialize, - message: 'Connector successfully created', - }) - } - - /** - * Show a connector - */ - static async show (req, res) { - const { connector_id } = req.params - - const connector = await models.Connector.findById(connector_id) - - if (!connector) { - throw new NotFoundError('Connector') - } - - return renderOk(res, { - results: connector.serialize, - message: 'Connector successfully found', - }) - } - - /* - * Update a connector - */ - static async update (req, res) { - const { connector_id } = req.params - - const connector = await global.models.Connector.findOneAndUpdate( - { _id: connector_id }, - { $set: filter(req.body, permittedUpdate) }, { new: true } - ).populate('channels') - - return renderOk(res, { - results: connector.serialize, - message: 'Connector successfully updated', - }) - } - - /** - * Delete a connector - */ - static async delete (req, res) { - const { connector_id } = req.params - - const connector = await models.Connector.findById(connector_id) - .populate('channels conversations') - - if (!connector) { - throw new NotFoundError('Connector') - } - - await Promise.all([ - ...connector.conversations.map(c => c.remove()), - ...connector.channels.map(c => c.remove()), - connector.remove(), - ]) - - return renderDeleted(res, 'Connector successfully deleted') - } - -} diff --git a/src/controllers/Conversations.controller.js b/src/controllers/Conversations.controller.js deleted file mode 100644 index 525d332..0000000 --- a/src/controllers/Conversations.controller.js +++ /dev/null @@ -1,83 +0,0 @@ -import { renderOk, renderDeleted } from '../utils/responses' -import { NotFoundError, BadRequestError } from '../utils/errors' - -export default class ConversationController { - - static async index (req, res) { - const { connector_id } = req.params - - const conversations = await models.Conversation.find({ connector: connector_id }) - - return renderOk(res, { - results: conversations.map(c => c.serialize), - message: conversations.length ? 'Conversations successfully found' : 'No conversations', - }) - } - - static async show (req, res) { - const { conversation_id, connector_id } = req.params - - const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) - .populate('participants messages') - - if (!conversation) { - throw new NotFoundError('Conversation') - } - - return renderOk(res, { - results: conversation.full, - message: 'Conversation successfully found', - }) - } - - static async delete (req, res) { - const { connector_id, conversation_id } = req.params - - const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) - - if (!conversation) { - throw new NotFoundError('Conversation') - } - - await conversation.remove() - - return renderDeleted(res, 'Conversation successfully deleted') - } - /* - * Find or create a conversation - */ - static async findOrCreateConversation (channelId, chatId) { - let conversation = await global.models.Conversation.findOne({ channel: channelId, chatId }) - .populate('channel') - .populate('connector', 'url _id') - .populate('participants') - .exec() - - if (conversation) { - return conversation - } - - const channel = await global.models.Channel.findById(channelId).populate('connector').exec() - - if (!channel) { - throw new NotFoundError('Channel') - } else if (!channel.isActivated) { - throw new BadRequestError('Channel is not activated') - } - - const connector = channel.connector - - if (!connector) { - throw new NotFoundError('Bot') - } - - conversation = await new global.models.Conversation({ connector: connector._id, chatId, channel: channel._id }).save() - connector.conversations.push(conversation._id) - await global.models.Connector.update({ _id: connector._id }, { $push: { conversations: conversation._id } }) - - conversation.connector = connector - conversation.channel = channel - return conversation - } - -} diff --git a/src/controllers/Messages.controller.js b/src/controllers/Messages.controller.js deleted file mode 100644 index d207f2a..0000000 --- a/src/controllers/Messages.controller.js +++ /dev/null @@ -1,225 +0,0 @@ -import _ from 'lodash' - -import Logger from '../utils/Logger' -import { invoke, invokeSync } from '../utils' -import { renderCreated } from '../utils/responses' -import { isValidFormatMessage } from '../utils/format' -import { NotFoundError, BadRequestError, ServiceError } from '../utils/errors' - -export default class MessagesController { - - static async pipeMessage (id, message, options) { - return global.controllers.Conversations.findOrCreateConversation(id, options.chatId) - .then(conversation => global.controllers.Messages.updateConversationWithMessage(conversation, message, options)) - .then(global.controllers.Messages.parseChannelMessage) - .then(global.controllers.Messages.saveMessage) - .then(global.controllers.Webhooks.sendMessageToBot) - } - - static parseChannelMessage ([conversation, message, options]) { - return invoke(conversation.channel.type, 'parseChannelMessage', [conversation, message, options]) - } - - static async saveMessage ([conversation, message, options]) { - let participant = _.find(conversation.participants, p => p.senderId === options.senderId) - const type = conversation.channel.type - - if (!participant) { - participant = await new global.models.Participant({ senderId: options.senderId }).save() - - await global.models.Conversation.update({ _id: conversation._id }, { $push: { participants: participant._id } }) - conversation.participants.push(participant) - } - - const newMessage = new global.models.Message({ - participant: participant._id, - conversation: conversation._id, - attachment: message.attachment, - }) - - conversation.messages.push(newMessage) - - return Promise.all([ - conversation, - newMessage.save(), - options, - global.models.Conversation.update({ _id: conversation._id }, { $push: { messages: newMessage._id } }), - ]) - } - - /** - * Check if all the messages received from the bot are well formatted - */ - static async bulkCheckMessages ([conversation, messages, opts]) { - if (!Array.isArray(messages)) { - throw new BadRequestError('Message is not well formated') - } - - for (const message of messages) { - if (!isValidFormatMessage(message)) { - throw new BadRequestError('Message is not well formated') - } - } - - return Promise.all([conversation, messages, opts]) - } - - /** - * Save an array of message in db - */ - static async bulkSaveMessages ([conversation, messages, opts]) { - let participant = _.find(conversation.participants, p => p.isBot) - - if (!participant) { - participant = await new global.models.Participant({ senderId: conversation.connector._id, isBot: true }).save() - conversation.participants.push(participant) - } - - messages = await Promise.all(messages.map(attachment => { - const newMessage = new global.models.Message({ - participant: participant._id, - conversation: conversation._id, - attachment, - }) - conversation.messages.push(newMessage) - return newMessage.save() - })) - - return Promise.all([ - conversation.save(), - messages, - opts, - ]) - } - - /** - * Format an array of messages - */ - static async bulkFormatMessages ([conversation, messages, options]) { - const channelType = conversation.channel.type - - messages = messages - .filter(message => !message.attachment.only || message.attachment.only.indexOf(channelType) !== -1) - - messages = await Promise.all(messages - .map(async (message) => { - const res = await invoke(channelType, 'formatMessage', [conversation, message, options]) - return Array.isArray(res) ? res : [res] - })) - - // flattening - messages = [].concat.apply([], messages) - return Promise.resolve([conversation, messages, options]) - } - - /** - * Send an array of messages to the bot - */ - static async bulkSendMessages ([conversation, messages, opts]) { - const channelType = conversation.channel.type - - for (const message of messages) { - try { - await invoke(channelType, 'sendMessage', [conversation, message, opts]) - } catch (err) { - throw new ServiceError('Error while sending message', err) - } - } - - return ([conversation, messages, opts]) - } - - static async postMessage (req, res) { - const { connector_id, conversation_id } = req.params - let { messages } = req.body - - if (!messages) { - throw new BadRequestError('Invalid \'messages\' parameter') - } else if (typeof messages === 'string') { - try { - messages = JSON.parse(messages) - } catch (err) { - throw new BadRequestError('Invalid \'messages\' parameter') - } - } - - const conversation = await global.models.Conversation.findOne({ _id: conversation_id, connector: connector_id }) - .populate('participants channel connector').exec() - - if (!conversation) { throw new NotFoundError('Conversation') } - - const participant = conversation.participants.find(p => !p.isBot) - if (!participant) { throw new NotFoundError('Participant') } - - const opts = { - senderId: participant.senderId, - chatId: conversation.chatId, - } - - await global.controllers.Messages.bulkCheckMessages([conversation, messages, opts]) - .then(global.controllers.Messages.bulkSaveMessages) - .then(global.controllers.Messages.bulkFormatMessages) - .then(global.controllers.Messages.bulkSendMessages) - - return renderCreated(res, { results: null, message: 'Messages successfully posted' }) - } - - static async postToConversation (conversation, messages) { - for (const participant of conversation.participants) { - if (participant.isBot) { continue } - - const opts = { - chatId: conversation.chatId, - senderId: participant.senderId, - } - - await new Promise((resolve, reject) => { - MessagesController.bulkSaveMessages([conversation, messages, opts]) - .then(MessagesController.bulkFormatMessages) - .then(MessagesController.bulkSendMessages) - .then(resolve) - .catch(reject) - }) - } - } - - static async broadcastMessage (req, res) { - const { connector_id } = req.params - let { messages } = req.body - - if (!messages || !Array.isArray(messages)) { - throw new BadRequestError('Invalid messages parameter') - } else if (typeof messages === 'string') { - try { - messages = JSON.parse(messages) - } catch (e) { - throw new BadRequestError('Invalid messages parameter') - } - } - - const connector = await models.Connector.findById(connector_id).populate('conversations') - - if (!connector) { - throw new NotFoundError('Connector') - } - - for (const conversation of connector.conversations) { - try { - await global.controllers.Messages.postToConversation(conversation, messages) - } catch (err) { - Logger.error('Error while broadcasting message', err) - } - } - - return renderCreated(res, { results: null, message: 'Messages successfully posted' }) - } - - /* - * Helpers - */ - - static async updateConversationWithMessage (conversation, message, options) { - return invoke(conversation.channel.type, 'updateConversationWithMessage', [conversation, message, options]) - } - -} diff --git a/src/controllers/Participants.controller.js b/src/controllers/Participants.controller.js deleted file mode 100644 index 3ffa5df..0000000 --- a/src/controllers/Participants.controller.js +++ /dev/null @@ -1,40 +0,0 @@ -import { NotFoundError } from '../utils/errors' -import { renderOk } from '../utils/responses' - -export default class ParticipantController { - - /* - * Index all connector's participants - */ - static async index (req, res) { - const { connector_id } = req.params - const results = [] - - const conversations = await models.Conversation.find({ connector: connector_id }).populate('participants') - - conversations.forEach(c => { - c.participants.forEach(p => results.push(p.serialize)) - }) - - return renderOk(res, { - results, - messages: results.length ? 'Participants successfully rendered' : 'No participants', - }) - } - - /* - * Show a participant - */ - static async show (req, res) { - const { participant_id } = req.params - - const participant = await models.Participant.findById(participant_id) - - if (!participant) { throw new NotFoundError('Participant') } - - return renderOk(res, { - results: participant.serialize, - message: 'Participant successfully rendered', - }) - } -} diff --git a/src/controllers/Webhooks.controller.js b/src/controllers/Webhooks.controller.js deleted file mode 100644 index 1612e94..0000000 --- a/src/controllers/Webhooks.controller.js +++ /dev/null @@ -1,79 +0,0 @@ -import request from 'superagent' - -import { invoke, invokeSync } from '../utils' -import { NotFoundError, BadRequestError } from '../utils/errors' - -export default class WebhooksController { - /** - * Receive a new message from a channel - * Retrieve the proper channel - * Invoke beforePipeline, extractOptions and checkSecurity - * Call the pipeline - */ - static async forwardMessage (req, res) { - const { channel_id } = req.params - let channel = await models.Channel.findById(channel_id).populate('children').populate('connector') - - if (!channel) { - throw new NotFoundError('Channel') - } else if (!channel.isActivated) { - throw new BadRequestError('Channel is not activated') - } else if (!channel.type) { - throw new BadRequestError('Type is not defined') - } - - await invoke(channel.type, 'checkSecurity', [req, res, channel]) - - channel = await invoke(channel.type, 'beforePipeline', [req, res, channel]) - const options = invokeSync(channel.type, 'extractOptions', [req, res, channel]) - - if (channel.connector.isTyping) { - invoke(channel.type, 'sendIsTyping', [channel, options, req.body]) - } - - const message = await invoke(channel.type, 'getRawMessage', [channel, req, options]) - - await controllers.Messages.pipeMessage(channel._id, message, options) - } - - static async subscribeWebhook (req, res) { - const { channel_id } = req.params - const channel = await global.models.Channel.findById(channel_id) - - if (!channel) { - throw new NotFoundError('Channel') - } - - return invoke(channel.type, 'onWebhookChecking', [req, res, channel]) - } - - /** - * Send a message to a bot - */ - static sendMessageToBot ([conversation, message, opts]) { - return new Promise((resolve, reject) => { - request.post(conversation.connector.url) - .set('Accept', 'application/json') - .set('Content-Type', 'application/json') - .send({ message, chatId: opts.chatId, senderId: opts.senderId }) - .end((err, response) => { - if (err) { return reject(err) } - return resolve([conversation, response.body, opts]) - }) - }) - } - - /** - * Send a message to a channel - */ - static async sendMessage ([conversation, messages, opts]) { - const channelType = conversation.channel.type - - for (const message of messages) { - await invoke(channelType, 'sendMessage', [conversation, message, opts]) - } - - return conversation - } - -} diff --git a/src/controllers/App.controller.js b/src/controllers/application.js similarity index 100% rename from src/controllers/App.controller.js rename to src/controllers/application.js diff --git a/src/controllers/channels.js b/src/controllers/channels.js new file mode 100644 index 0000000..12a1f3a --- /dev/null +++ b/src/controllers/channels.js @@ -0,0 +1,187 @@ +import filter from 'filter-object' + +import * as channelConstants from '../constants/channels' +import { NotFoundError, ConflictError } from '../utils/errors' +import { renderOk, renderCreated, renderDeleted } from '../utils/responses' +import { Channel, Connector } from '../models' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' + +export default class ChannelsController { + + /** + * Create a new channel + */ + static async create (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + const params = filter(req.body, channelConstants.permitted) + const slug = params.slug + + if (!connector) { + throw new NotFoundError('Connector') + } else if (await Channel.findOne({ + connector: connector._id, + isActive: true, + slug })) { + throw new ConflictError('Channel slug is already taken') + } + const channel = await new Channel({ ...params, + connector: connector._id }) + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + channel.webhook = channelIntegration.buildWebhookUrl(channel) + await channelIntegration.beforeChannelCreated(channel, req) + await channel.save() + await channelIntegration.afterChannelCreated(channel, req) + + return renderCreated(res, { + results: channel.serialize, + message: 'Channel successfully created', + }) + } + + /** + * Index channels + */ + static async index (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + if (!connector) { + throw new NotFoundError('Connector') + } + + const channels = await Channel.find({ + connector: connector._id, + }) + + return renderOk(res, { + results: channels.map(c => c.serialize), + message: channels.length ? 'Channels successfully rendered' : 'No channels', + }) + } + + /** + * Show a channel + */ + static async show (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const channel_slug = req.params.channel_slug + + if (!connector) { + throw new NotFoundError('Connector') + } + + const channel = await Channel.findOne({ + slug: channel_slug, + connector: connector._id, + isActive: true, + }).populate('children') + + if (!channel) { + throw new NotFoundError('Channel') + } + + return renderOk(res, { + results: channel.serialize, + message: 'Channel successfully rendered', + }) + } + + /** + * Index a channel's redirection_groups + */ + static async getCRMRedirectionGroups (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const channel_slug = req.params.channel_slug + + if (!connector) { + throw new NotFoundError('Connector') + } + + const channel = await Channel.findOne({ + slug: channel_slug, + connector: connector._id, + isActive: true, + }) + + if (!channel) { + throw new NotFoundError('Channel') + } + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + const redirectionGroups = await channelIntegration.getCRMRedirectionGroups(channel) + return renderOk(res, { + results: redirectionGroups, + message: 'Redirection groups successfully rendered', + }) + } + + /** + * Update a channel + */ + static async update (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const channel_slug = req.params.channel_slug + const slug = req.body.slug + const params = filter(req.body, channelConstants.permittedUpdate) + + if (!connector) { + throw new NotFoundError('Connector') + } + + const oldChannel = await Channel + .findOne({ connector: connector._id, isActive: true, slug: channel_slug }) + + if (slug && slug !== channel_slug && await Channel.findOne({ + connector: connector._id, + isActive: true, + slug, + })) { + throw new ConflictError('Channel slug is already taken') + } + + const channel = await Channel.findOneAndUpdate( + { slug: channel_slug, connector: connector._id, isActive: true }, + { $set: { ...params } }, + { new: true } + ) + + if (!channel) { + throw new NotFoundError('Channel') + } + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.afterChannelUpdated(channel, oldChannel) + + return renderOk(res, { + results: channel.serialize, + message: 'Channel successfully updated', + }) + } + + /** + * Delete a channel + */ + static async delete (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const channel_slug = req.params.channel_slug + + if (!connector) { + throw new NotFoundError('Connector') + } + + const channel = await Channel + .findOne({ connector: connector._id, isActive: true, slug: channel_slug }) + if (!channel) { + throw new NotFoundError('Channel') + } + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.beforeChannelDeleted(channel) + + channel.isActive = false + await Promise.all([ + channel.save(), + channelIntegration.afterChannelDeleted(channel), + ]) + + return renderDeleted(res, 'Channel successfully deleted') + } + +} diff --git a/src/controllers/connectors.js b/src/controllers/connectors.js new file mode 100644 index 0000000..f28aac0 --- /dev/null +++ b/src/controllers/connectors.js @@ -0,0 +1,122 @@ +import filter from 'filter-object' + +import { BadRequestError, NotFoundError } from '../utils/errors' +import { renderOk, renderCreated, renderDeleted } from '../utils/responses' +import { Connector, Channel, Conversation } from '../models' + +const permittedAdd = '{url,isTyping,defaultDelay}' +const permittedUpdate = '{url,isTyping,defaultDelay}' + +export default class Connectors { + + /** + * Create a new connector + */ + static async create (req, res) { + const payload = filter(req.body, permittedAdd) + + const connector = await new Connector(payload).save() + const result = connector.serialize + result.conversations = [] + result.channels = [] + + return renderCreated(res, { + results: result, + message: 'Connector successfully created', + }) + } + + /** + * Show a connector + */ + static async show (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + if (!connector) { + throw new NotFoundError('Connector') + } + + const result = connector.serialize + result.channels = await Channel.find({ connector: connector._id, isActive: true }) + result.channels = result.channels.map(c => c.serialize || c) + if (req.query.light !== 'true') { + result.conversations = await Conversation.find( + { connector: connector._id }, + { _id: 1 }) + result.conversations = result.conversations.map(c => c._id) + } + + return renderOk(res, { + results: result, + message: 'Connector successfully found', + }) + } + + /* + * Update a connector + */ + static async update (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + if (!connector) { + throw new NotFoundError('Connector') + } + + const delay = req.body.defaultDelay + // isNaN(null) is a Number + if (delay !== undefined && (isNaN(delay) || delay < 0 || delay > 5)) { + throw new BadRequestError('defaultDelay parameter is invalid') + } + + const updatedConnector = await Connector + .findOneAndUpdate({ _id: connector._id }, + { $set: filter(req.body, permittedUpdate) }, { new: true } + ) + + const result = updatedConnector.serialize + result.channels = await Channel.find({ connector: connector._id, isActive: true }) + result.channels = result.channels.map(c => c.serialize || c) + result.conversations = await Conversation.find( + { connector: connector._id }, + { _id: 1 }) + result.conversations = result.conversations.map(c => c._id) + + return renderOk(res, { + results: result, + message: 'Connector successfully updated', + }) + } + + /** + * Delete a connector + */ + static async delete (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + if (!connector) { + throw new NotFoundError('Connector') + } + + await recursiveDestroy(connector) + await Connector.updateOne({ _id: connector._id }, { $set: { isActive: false } }) + + return renderDeleted(res, 'Connector successfully deleted') + } + +} + +/* + * Helpers + */ + +/** + * Recursively delete all the channels and the conversations associated to a connector + */ +const recursiveDestroy = async (connector) => { + await Channel.update({ connector: connector._id }, + { $set: { isActive: false } }, + { multi: true }) + await Conversation.update({ connector: connector._id }, + { $set: { isActive: false } }, + { multi: true }) +} diff --git a/src/controllers/conversations.js b/src/controllers/conversations.js new file mode 100644 index 0000000..4345da0 --- /dev/null +++ b/src/controllers/conversations.js @@ -0,0 +1,190 @@ +import _ from 'lodash' +import moment from 'moment' +import archiver from 'archiver' + +import { renderOk, renderDeleted } from '../utils/responses' +import { logger, fmtConversationHeader, sendMail, + sendArchiveByMail, fmtMessageDate } from '../utils' +import { NotFoundError } from '../utils/errors' +import { Connector, Conversation, Message, Participant } from '../models' + +export default class ConversationController { + + static async index (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + if (!connector) { + throw new NotFoundError('Connector') + } + + const conversations = await Conversation + .find({ connector: connector._id, isActive: true }) + + return renderOk(res, { + results: conversations.map(c => c.serialize), + message: conversations.length ? 'Conversations successfully found' : 'No conversations', + }) + } + + static async show (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const conversation_id = req.params.conversation_id + + if (!connector) { + throw new NotFoundError('Connector') + } + + const conversation = await Conversation.findOne({ + _id: conversation_id, + connector: connector._id, + isActive: true, + }) + + if (!conversation) { + throw new NotFoundError('Conversation') + } + + const result = conversation.full + result.participants = await Participant.find({ conversation: conversation._id }) + result.participants = result.participants.map(p => p.serialize) + result.messages = await Message.find({ + conversation: conversation._id, + isActive: true, + }).sort('receivedAt') + result.messages = result.messages.map(m => m.serialize) + + return renderOk(res, { + results: result, + message: 'Conversation successfully found', + }) + } + + static async delete (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const conversation_id = req.params.conversation_id + + if (!connector) { + throw new NotFoundError('Connector') + } + + const conversation = await Conversation.findOne({ + _id: conversation_id, + connector: connector._id, + isActive: true, + }) + + if (!conversation) { + throw new NotFoundError('Conversation') + } + + conversation.isActive = false + await conversation.save() + + return renderDeleted(res, 'Conversation successfully deleted') + } + + /* + * Accepted parameters + * - by: 'zip' || 'mail' - either returns the conversations as an archive in the + * request response, or send it by mail to the adresses specified + * - to: "jerome.houdan@sap.com,jerome.houdan@gmail.com" - a list of email adresses + * separated by a comma + * - from: "jerome.houdan@sap.com" - an email adress used as the sender + * - populate: true || false - determine if we add the participant information and + * the reception date of the messages or not + */ + static async dumpDelete (req, res) { + const dump = [] + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const { to, from, populate } = req.body + const by = req.body.by || 'mail' + const cutoff = moment().subtract(12, 'hours') + let allConversations = '' + + if (!connector) { + throw new NotFoundError('Connector') + } + + const convModels = await Conversation + .find({ connector: connector._id, createdAt: { $lt: cutoff } }, 'createdAt') + + const conversations = await Promise.all(convModels.map(async (conv) => { + const participants = await Participant.find({ conversation: conv._id }) + const messages = await Message + .find({ conversation: conv._id }, { attachment: 1, participant: 1, receivedAt: 1 }) + .populate('participant', 'isBot') + .sort('receivedAt') + return { conv, participants, messages } + })) + + conversations + .filter(c => c.messages.length) + .forEach(({ conv, participants, messages }, i) => { + const conversationHeader = populate + ? fmtConversationHeader(conv, participants) + : '' + + const content = messages.reduce((tmp, m) => { + const messageContent = _.get(m, 'attachment.content', 'Empty message') + const content = typeof messageContent === 'string' + ? messageContent + : `${messageContent.title}\n${messageContent.buttons.map(b => b.title).join(' | ')}` + + const participantType = _.get(m, 'participant.isBot', false) ? 'BOT' : 'USER' + + return populate + ? `${tmp}${participantType} ${fmtMessageDate(m)} > ${content}\n` + : `${tmp}${participantType} > ${content}\n` + }, conversationHeader) + allConversations += `${content}\n\n---------------------------\n\n` + dump.push({ content: new Buffer(content, 'utf-8'), filename: `conversation-${i}.txt` }) + }) + + if (dump.length && by === 'mail') { + dump.push({ content: new Buffer(allConversations, 'utf-8'), filename: 'conversations.txt' }) + await sendArchiveByMail({ + to, + from, + subject: 'SAP Conversational AI daily logs', + text: 'Bonjour\n\nCi-joint les logs quotidiens du chatbot.\n\nCordialement,', + attachments: dump, + }) + } else if (by === 'zip') { + const archive = archiver('zip') + res.attachment('conversations.zip') + + archive.on('end', () => res.end()) + archive.pipe(res) + + for (const file of dump) { + archive.append(file.content, { name: file.filename }) + } + + archive.finalize() + } else { + await sendMail({ + to, + from, + subject: `Bot Connector ${connector._id}: Daily logs`, + text: 'Bonjour\n\nIl n\'y a pas de logs de conversation aujourd\'hui.\n\nCordialement,', + }) + } + + const conversationIds = conversations.map(c => c.conv._id) + try { + // Remove all the conversations, along with the messages and the + // participants belonging to the conversations + await Promise.all([ + Conversation.remove({ _id: { $in: conversationIds } }), + Message.remove({ conversation: { $in: conversationIds } }), + Participant.remove({ conversation: { $in: conversationIds } }), + ]) + } catch (err) { + logger.error(`Error while deleting conversations: ${err}`) + } + + if (by !== 'zip') { + return renderDeleted(res) + } + } +} diff --git a/src/controllers/get_started_buttons.js b/src/controllers/get_started_buttons.js new file mode 100644 index 0000000..a446d7e --- /dev/null +++ b/src/controllers/get_started_buttons.js @@ -0,0 +1,154 @@ +import { renderOk, renderCreated, renderDeleted } from '../utils/responses' +import { Channel, Connector, GetStartedButton } from '../models' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' +import { NotFoundError, renderError } from '../utils' +import { ConflictError } from '../utils/errors' + +export default class GetStartedButtonController { + /** + * Create a new GetStartedButton + */ + static async create (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + + const channel_id = req.params.channel_id + const channel = await Channel.findOne({ + connector: connector._id, + _id: channel_id, + }) + if (!channel) { + throw new NotFoundError('Channel') + } + + const getStarted = await GetStartedButton.findOne({ + channel_id: channel._id, + }) + if (getStarted) { + throw new ConflictError('A GetStartedButton already exists') + } + const getStartedButtonValue = req.body.value + + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + try { + await channelIntegration.setGetStartedButton(channel, getStartedButtonValue, connector) + } catch (err) { + return renderError(res, err) + } + const getStartedButton = await new GetStartedButton({ + channel_id: channel._id, + value: getStartedButtonValue, + }) + await getStartedButton.save() + channel.hasGetStarted = true + await channel.save() + + return renderCreated(res, { + results: getStartedButton.serialize, + message: 'GetStartedButton successfully created', + }) + } + + /** + * Show a GetStartedButton + */ + static async show (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + + const channel_id = req.params.channel_id + const channel = await Channel.findOne({ + connector: connector._id, + _id: channel_id, + }) + if (!channel) { + throw new NotFoundError('Channel') + } + + const getStartedButton = await GetStartedButton.findOne({ channel_id: channel._id }) + if (!getStartedButton) { + throw new NotFoundError('GetStartedButton') + } + + return renderOk(res, { + results: getStartedButton.serialize, + message: 'getStartedButton successfully rendered', + }) + } + + /** + * Update a GetStartedButton + */ + static async update (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + const channel_id = req.params.channel_id + const channel = await Channel.findOne({ + connector: connector._id, + _id: channel_id, + }) + if (!channel) { + throw new NotFoundError('Channel') + } + + const getStartedButton = await GetStartedButton.findOne({ channel_id: channel._id }) + if (!getStartedButton) { + throw new NotFoundError('GetStartedButton') + } + const newValue = req.body.value + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + try { + await channelIntegration.setGetStartedButton(channel, newValue) + } catch (err) { + return renderError(res, err) + } + getStartedButton.value = newValue + await getStartedButton.save() + + return renderOk(res, { + results: getStartedButton.serialize, + message: 'GetStartedButton successfully updated', + }) + } + + /** + * Delete a GetStartedButton + */ + static async delete (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + const channel_id = req.params.channel_id + const channel = await Channel.findOne({ + connector: connector._id, + _id: channel_id, + }) + if (!channel) { + throw new NotFoundError('Channel') + } + + const getStartedButton = await GetStartedButton.findOne({ channel_id: channel._id }) + if (!getStartedButton) { + throw new NotFoundError('GetStartedButton') + } + + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + try { + await channelIntegration.deleteGetStartedButton(channel) + } catch (err) { + return renderError(res, err) + } + await getStartedButton.remove() + channel.hasGetStarted = false + await channel.save() + + return renderDeleted(res, 'GetStartedButton successfully deleted') + } +} diff --git a/src/controllers/index.js b/src/controllers/index.js deleted file mode 100644 index a66faa9..0000000 --- a/src/controllers/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import App from './App.controller' -import Connectors from './Connectors.controller' -import Channels from './Channels.controller' -import Messages from './Messages.controller' -import Webhooks from './Webhooks.controller' -import Conversations from './Conversations.controller' -import Participants from './Participants.controller' - -export default { - App, - Connectors, - Channels, - Messages, - Webhooks, - Conversations, - Participants, -} diff --git a/src/controllers/message_pipe.js b/src/controllers/message_pipe.js new file mode 100644 index 0000000..87a19f1 --- /dev/null +++ b/src/controllers/message_pipe.js @@ -0,0 +1,173 @@ +import _ from 'lodash' +import MessageController from './messages' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' +import WebhookController from './webhooks' +import { Channel, Conversation, Message, Participant } from '../models' +import { logger, sendToWatchers } from '../utils' +import { BadRequestError, NotFoundError } from '../utils/errors' + +/** + * Encapsulates handling of a message sent to a channel's webhook. + * Currently a WIP: First move all "controller" and "util" functions into this class, then refactor. + */ +class MessagePipe { + constructor (channel, context) { + this.channel = channel + this.messageContext = context + this.channelIntegration = getChannelIntegrationByIdentifier(channel.type) + } + + /** + * Runs the message pipe for a given input message + * @param message The message to be processed + * @return {Promise<*>} The response object returned by the bot + */ + async run (message) { + let conversation = await this.findOrCreateConversation() + conversation = this.channelIntegration.updateConversationContextFromMessage( + conversation, message) + await conversation.save() + + this.messageContext.mentioned = true + const parsedMessage = await this.parseIncomingMessage(conversation, message) + const memoryOptions = await this.getMemoryOptions(message) + const savedMessage = await this.saveMessage(conversation, parsedMessage) + await this.sendMessageToWatchers(conversation, savedMessage) + if (this.channel.connector.isTyping) { + const echo = _.get(message, 'entry[0].messaging[0]', {}) + // don't send isTyping if the message is an echo from facebook + if (this.channel.type !== 'messenger' || (echo.message && !(echo.is_echo && echo.app_id))) { + await this.channelIntegration.onIsTyping( + this.channel, this.messageContext) + } + } + + const botResponse = await WebhookController.sendMessageToBot( + [conversation, savedMessage, memoryOptions, this.messageContext] + ) + // save original response from rafiki / custom bot to context so it can be used later if needed + this.messageContext.originalBotResponse = botResponse.results || botResponse + return this.sendRepliesToChannel(conversation, botResponse) + } + + async sendMessageToWatchers (conversation, message) { + if (['webchat', 'recastwebchat'].includes(conversation.channel.type)) { + try { + message.participant = await Participant.findById(message.participant) + } catch (err) { + logger.error('Could not populate participant', err) + } + sendToWatchers(conversation._id, [message.serialize]) + } + } + + async parseIncomingMessage (conversation, message) { + return this.channelIntegration.parseIncomingMessage( + conversation, + message, + this.messageContext) + } + + async getMemoryOptions (message) { + return this.channelIntegration.getMemoryOptions(message, this.messageContext) + } + + async findOrCreateConversation () { + const chatId = this.messageContext.chatId + const channelId = this.channel._id + let conversation + = await Conversation.findOne({ channel: channelId, chatId, isActive: true }) + .populate('channel') + .populate('connector') + .exec() + + if (conversation && conversation.isActive) { + return conversation + } + + const channel = await Channel.findById(channelId).populate('connector').exec() + + if (!channel || !channel.isActive) { + throw new NotFoundError('Channel') + } else if (!channel.isActivated) { + throw new BadRequestError('Channel is not activated') + } + + const connector = channel.connector + + if (!connector) { + throw new NotFoundError('Connector') + } + + conversation = await new Conversation({ + connector: connector._id, + chatId, + channel: channel._id, + }).save() + + conversation.connector = connector + conversation.channel = channel + return conversation + } + + async sendRepliesToChannel (conversation, replies) { + const { results } = replies + let { messages } = replies + // Rafiki sends a json with an object "results" wrapping data + // Since we decided that it would be weird for users to do the same thing + // We look for the object "messages" both at the root and in results + if (results && results.messages) { + messages = results.messages + _.set(this.messageContext, 'memory', _.get(results, 'conversation.memory', {})) + } else { + _.set(this.messageContext, 'memory', _.get(replies, 'conversation.memory', {})) + } + + if (!messages) { + return [] + } + messages = MessageController.checkAndTransformMessages(messages) + return MessageController.sendMessagesToChannel( + [conversation, messages, this.messageContext]) + } + + async saveMessage (conversation, message) { + let participant = await Participant.findOne({ + conversation: conversation._id, + senderId: this.messageContext.senderId, + }) + + if (!participant) { + participant = await new Participant({ + conversation: conversation._id, + senderId: this.messageContext.senderId, + type: 'user', + }).save() + } + try { + const updatedParticipant + = await this.channelIntegration.populateParticipantData(participant, conversation.channel) + participant = updatedParticipant + } catch (err) { + logger.error(`Unable to get user infos: ${err}`) + } + + const newMessage = new Message({ + participant: participant._id, + conversation: conversation._id, + attachment: message.attachment, + }) + + if (_.get(message, 'newMessage.attachment.delay')) { + newMessage.delay = newMessage.attachment.delay + } + + this.messageContext.participantData + = this.channelIntegration.parseParticipantDisplayName(participant) + + return newMessage.save() + } + +} + +export default MessagePipe diff --git a/src/controllers/messages.js b/src/controllers/messages.js new file mode 100644 index 0000000..fa88fcf --- /dev/null +++ b/src/controllers/messages.js @@ -0,0 +1,252 @@ +import _ from 'lodash' +import { logger } from '../utils' +import { renderCreated } from '../utils/responses' +import { isValidFormatMessage } from '../utils/format' +import { BadRequestError, NotFoundError, ServiceError } from '../utils/errors' +import { Connector, Conversation, Message, Participant } from '../models' +import MessageController from '../controllers/messages' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' + +export default class MessagesController { + + static checkAndTransformMessages (messages) { + if (!messages) { + throw new BadRequestError('Invalid \'messages\' parameter') + } else if (typeof messages === 'string') { + try { + messages = JSON.parse(messages) + } catch (err) { + throw new BadRequestError('Invalid \'messages\' parameter') + } + } + return messages + } + + static async sendMessagesToChannel ([conversation, messages, context]) { + let participant = await Participant.findOne({ + conversation: conversation._id, + isBot: true, + type: context.type, + }) + + if (!participant) { + participant = await new Participant({ + conversation: conversation._id, + senderId: conversation.connector._id, + isBot: true, + type: context.type, + }).save() + } + + messages = await MessageController.bulkSetCorrectDelayForMessages([conversation, messages]) + const returned_messages = [] + for (const message of messages) { + await MessageController.checkMessage([conversation, message, context]) + .then(async ([conversation, message, context]) => { + [conversation, message, context] + = await MessageController.saveMessage(conversation, message, context, participant) + return [conversation, message, context, message.delay] + }) + .then(MessageController.formatMessage) + .then(([conversation, message, context, delay]) => { + returned_messages.push(message) + return MessageController.sendMessage([conversation, message, context, delay]) + }) + } + return returned_messages + } + + static async postMessage (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + let messages = req.body.messages + const conversationId = req.params.conversationId + + if (!connector) { + throw new NotFoundError('Connector') + } + + messages = MessageController.checkAndTransformMessages(messages) + + const conversation = await Conversation + .findOne({ _id: conversationId, connector: connector._id }) + .populate('channel connector') + .exec() + + if (!conversation) { throw new NotFoundError('Conversation') } + + const participant = await Participant + .findOne({ conversation: conversation._id, isBot: false }) + if (!participant) { throw new NotFoundError('Participant') } + + const context = { + senderId: participant.senderId, + chatId: conversation.chatId, + } + + await MessageController.sendMessagesToChannel([conversation, messages, context]) + + return renderCreated(res, { results: null, message: 'Messages successfully posted' }) + } + + static async broadcastMessage (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + let messages = req.body.messages + + if (!messages || !Array.isArray(messages)) { + throw new BadRequestError('Invalid messages parameter') + } else if (typeof messages === 'string') { + try { + messages = JSON.parse(messages) + } catch (e) { + throw new BadRequestError('Invalid messages parameter') + } + } + + if (!connector) { + throw new NotFoundError('Connector') + } + + const conversations + = await Conversation.find({ connector: connector._id, isActive: true }) + for (const conversation of conversations) { + try { + await MessageController.postToConversation(conversation, messages) + } catch (err) { + logger.error(`Error while broadcasting message: ${err}`) + } + } + + return renderCreated(res, { results: null, message: 'Messages successfully posted' }) + } + + /* + * Helpers + */ + + /** + * Check if the message received is well formatted + */ + static async checkMessage ([conversation, message, context]) { + if (!isValidFormatMessage(message)) { + throw new BadRequestError('Message is not well formatted') + } + + return Promise.all([conversation, message, context]) + } + + /** + * Save a message in db + */ + static async saveMessage (conversation, message, context, participant) { + if (!context.type) { + context.type = 'bot' + } + + const now = Date.now() + + const newMessage = new Message({ + participant: participant._id, + conversation: conversation._id, + attachment: message, + receivedAt: now, + }) + if (message.delay) { + newMessage.delay = message.delay + } + await newMessage.save() + + return Promise.all([ + conversation, + newMessage, + context, + ]) + } + + /** + * Extract the delay from the message and format the message. + * Go through each message, and set the message delay + * either to the specific delay provided in the message + * or to the default delay if it's not the last message + */ + static async bulkSetCorrectDelayForMessages ([conversation, messages]) { + const messages_length = messages.length + messages.map((message, i) => { + let message_delay = message.delay + if (!message_delay && message_delay !== 0 && conversation.connector.defaultDelay) { + if (i === messages_length - 1) { + message_delay = 0 + } else { + message_delay = conversation.connector.defaultDelay + } + } else { + if (!_.isFinite(message_delay) || message_delay < 0) { + message_delay = 0 + } + if (message_delay > 5) { + message_delay = 5 + } + } + message.delay = message_delay + return message + }) + + return messages + } + + /** + * Format a message + */ + static async formatMessage ([conversation, message, context, delay]) { + const channelType = conversation.channel.type + + if (message.attachment.only && !message.attachment.only.indexOf(channelType) !== -1) { + return Promise.resolve() + } + const channelIntegration = getChannelIntegrationByIdentifier(channelType) + message = await channelIntegration.formatOutgoingMessage(conversation, message, context) + return Promise.resolve([conversation, message, context, delay]) + } + + static async delayNextMessage (delay, conversation, channelIntegration, context) { + if (delay) { + if (conversation.connector.isTyping) { + await channelIntegration.onIsTyping(conversation.channel, context) + } + return new Promise(resolve => setTimeout(resolve, delay * 1000)) + } + } + + /** + * Send a message to the user + */ + static async sendMessage ([conversation, message, context, delay]) { + const channelType = conversation.channel.type + const channelIntegration = getChannelIntegrationByIdentifier(channelType) + try { + await channelIntegration.sendMessage(conversation, message, context) + await MessageController.delayNextMessage(delay, conversation, channelIntegration, context) + } catch (err) { + logger.error('Failed to send messages', err) + throw new ServiceError('Error while sending message', err) + } + } + + static async postToConversation (conversation, messages) { + const participants + = await Participant.find({ conversation: conversation._id, isBot: false }) + for (const participant of participants) { + const context = { + chatId: conversation.chatId, + senderId: participant.senderId, + } + + messages = await MessageController.bulkSetCorrectDelayForMessages([conversation, messages]) + for (const message of messages) { + await MessagesController.saveMessage([conversation, message, context], participant) + .then(MessagesController.formatMessage) + .then(MessagesController.sendMessage) + } + } + } + +} diff --git a/src/controllers/participants.js b/src/controllers/participants.js new file mode 100644 index 0000000..13cd3cc --- /dev/null +++ b/src/controllers/participants.js @@ -0,0 +1,51 @@ +import { renderOk } from '../utils/responses' +import { NotFoundError } from '../utils/errors' +import { Connector, Conversation, Participant } from '../models' + +export default class ParticipantController { + + static async index (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + + if (!connector) { + throw new NotFoundError('Connector') + } + + const conversationsIds = await Conversation + .find({ connector: connector._id, isActive: true }, { _id: 1 }) + + const participants = await Participant + .find({ conversation: { $in: conversationsIds.map(c => c._id) } }) + + return renderOk(res, { + results: participants.map(p => p.serialize), + message: participants.length ? 'Participants successfully rendered' : 'No participants', + }) + } + + static async show (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + const participant_id = req.params.participant_id + + if (!connector) { + throw new NotFoundError('Connector') + } + + const participant = await Participant.findById(participant_id) + if (!participant) { + throw new NotFoundError('Participant') + } + + const conversation = await Conversation + .findOne({ _id: participant.conversation, isActive: true }) + if (!conversation || conversation.connector !== connector._id) { + throw new NotFoundError('Participant') + } + + return renderOk(res, { + results: participant.serialize, + message: 'Participant successfully rendered', + }) + } + +} diff --git a/src/controllers/persistent_menus.js b/src/controllers/persistent_menus.js new file mode 100644 index 0000000..0a86ef2 --- /dev/null +++ b/src/controllers/persistent_menus.js @@ -0,0 +1,226 @@ +import { renderOk, renderCreated, renderDeleted } from '../utils/responses' +import { Channel, Connector, PersistentMenu } from '../models' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' +import { NotFoundError } from '../utils' +import { ConflictError } from '../utils/errors' + +export default class PersistentMenuController { + /** + * Create a new Persistent menu for a given language + */ + static async create (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + if (await PersistentMenu.findOne({ + connector_id: connector._id, + locale: req.body.language, + })) { + throw new ConflictError('A persistent menu already exists for this language') + } + // Retrieving all the channels to be updated with the new menu + const channels = await Channel.find({ connector: connector._id, isActive: true }) + + const newMenu = await new PersistentMenu({ + connector_id: connector._id, + menu: req.body.menu, + locale: req.body.language, + }) + // Retrieving existing menus for other languages + const existingMenus = await PersistentMenu.find({ connector_id: connector._id }) + if (!existingMenus.length) { + newMenu.default = true + } + existingMenus.push(newMenu) + await Promise.all(channels.map(async (channel) => { + try { + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.setPersistentMenu(channel, existingMenus) + } catch (err) {} //eslint-disable-line + })) + + await newMenu.save() + + return renderCreated(res, { + results: newMenu.serialize, + message: 'PersistentMenu successfully created', + }) + } + + /** + * Index persistent menus for all languages + */ + static async index (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + const existingMenus = await PersistentMenu.find({ connector_id: connector._id }) + + return renderOk(res, { + results: existingMenus.map(m => m.serialize), + message: existingMenus.length + ? 'Persistent menus successfully rendered' : 'No Persistent menu', + }) + } + + /** + * Show a persistent menu for a language + */ + static async show (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + const menu = await PersistentMenu.findOne({ + connector_id: connector.id, + locale: req.params.language, + }) + if (!menu) { + throw new NotFoundError('PersistentMenu') + } + + return renderOk(res, { + results: menu.serialize, + message: 'PersistentMenu successfully rendered', + }) + } + + /** + * set the given language menu to default + */ + static async setDefault (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + const language = req.body.language + // throw error if language menu does not exist + if (!await PersistentMenu.findOneAndUpdate( + { connector_id: connector._id, locale: language }, + { $set: { default: true } })) { + throw new NotFoundError('PersistentMenu') + } + // unset previous default menu + await PersistentMenu.findOneAndUpdate( + { connector_id: connector._id, default: true, locale: { $ne: language } }, + { $set: { default: false } }) + const existingMenus = await PersistentMenu.find({ connector_id: connector._id }) + + const channels = await Channel.find({ connector: connector._id, isActive: true }) + + await Promise.all(channels.map(async (channel) => { + try { + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.setPersistentMenu(channel, existingMenus) + } catch (err) {} //eslint-disable-line + })) + + return renderOk(res, { + message: 'Default menu successfully updated', + }) + } + + /** + * Update a menu for a given language + */ + static async update (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + + const language = req.params.language + const persistentMenu = await PersistentMenu.findOne({ + connector_id: connector._id, + locale: language, + }) + if (!persistentMenu) { + throw new NotFoundError('PersistentMenu') + } + persistentMenu.menu = req.body.menu + const existingMenus = await PersistentMenu.find({ + connector_id: connector._id, + locale: { $ne: language }, + }) + existingMenus.push(persistentMenu) + const channels = await Channel.find({ connector: connector._id, isActive: true }) + await Promise.all(channels.map(async (channel) => { + try { + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.setPersistentMenu(channel, existingMenus) + } catch (e) {} //eslint-disable-line + })) + + persistentMenu.markModified('menu') + await persistentMenu.save() + + return renderOk(res, { + results: persistentMenu.serialize, + message: 'PersistentMenu successfully updated', + }) + } + + /** + * Delete a persistent menu for a given language + * If it's the only menu for the connector, delete the property + * Else, update the property to all channels + */ + static async delete (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + const language = req.params.language + const menu = await PersistentMenu.findOne({ + connector_id: connector._id, + locale: language, + }) + if (!menu) { + throw new NotFoundError('PersistentMenu') + } else if (menu.default === true) { + // if deleted menu is default, set another menu to default + await PersistentMenu.findOneAndUpdate( + { connector_id: connector._id, default: false }, + { $set: { default: true } }) + } + const existingMenus = await PersistentMenu.find({ + connector_id: connector._id, + locale: { $ne: language }, + }) + + const channels = await Channel.find({ connector: connector._id, isActive: true }) + await Promise.all(channels.map(async (channel) => { + try { + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + if (!existingMenus.length) { + await channelIntegration.deletePersistentMenu(channel) + } else { + await channelIntegration.setPersistentMenu(channel, existingMenus) + } + } catch (e) {} //eslint-disable-line + })) + + await menu.remove() + + return renderDeleted(res, 'Persistent Menu successfully deleted') + } + + static async deleteAll (req, res) { + const connector = await Connector.findOne({ _id: req.params.connectorId, isActive: true }) + if (!connector) { + throw new NotFoundError('Connector') + } + await PersistentMenu.deleteMany({ connector_id: connector._id }) + const channels = await Channel.find({ connector: connector._id, isActive: true }) + await Promise.all(channels.map(async (channel) => { + try { + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.deletePersistentMenu(channel) + } catch (e) {} //eslint-disable-line + })) + + return renderDeleted(res, 'Persistent Menu successfully deleted') + } +} diff --git a/src/controllers/webhooks.js b/src/controllers/webhooks.js new file mode 100644 index 0000000..74156ca --- /dev/null +++ b/src/controllers/webhooks.js @@ -0,0 +1,316 @@ +import request from 'superagent' +import uuidV4 from 'uuid/v4' +import _ from 'lodash' + +import { logger, renderPolledMessages } from '../utils' +import { renderCreated, renderOk } from '../utils/responses' +import { BadRequestError, ForbiddenError, NotFoundError } from '../utils/errors' +import { Channel, Conversation, Message, Participant, PersistentMenu } from '../models' +import WebhookController from '../controllers/webhooks' +import messageQueue from '../utils/message_queue' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' +import MessagePipe from './message_pipe' + +export default class WebhooksController { + + static async serviceHandleMethodAction (req, res) { + const channel_type = req.params.channel_type + const channelIntegration = getChannelIntegrationByIdentifier(channel_type) + if (!channelIntegration) { + throw new BadRequestError('Channel type does not exist') + } + const messageMethods = channelIntegration.webhookHttpMethods() + /* Requests made on methods declared in 'webhookHttpMethods' for a channel type + will be treated as an incoming message */ + if (messageMethods.includes(req.method)) { + const identityPairs = channelIntegration.getIdPairsFromSharedWebhook(req, res) + if (!identityPairs) { + throw new NotFoundError('Channel identity') + } + const channel = await Channel + .find({ + ...identityPairs, + isActive: true, + }) + .sort({ createdAt: -1 }) + .limit(1) + .populate({ path: 'children connector' }) + .cursor() + .next() + if (!channel) { + throw new NotFoundError('Channel') + } + + await WebhookController.forwardMessage(req, res, channel) + return + } + + /* Requests made on other methods will be treated as a subscription request */ + await WebhookController.serviceSubscribeWebhook(req, res, channel_type) + } + + static async serviceSubscribeWebhook (req, res, channel_type) { + const channelIntegration = getChannelIntegrationByIdentifier(channel_type) + return channelIntegration.onSharedWebhookChecking(req, res) + } + + static async handleMethodAction (req, res) { + const channel_id = req.params.channel_id + const channel = await Channel + .findById(channel_id) + .populate({ path: 'children connector' }) + + if (!channel) { + throw new NotFoundError('Channel') + } + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + const messageMethods = channelIntegration.webhookHttpMethods() + /* Requests made on methods declared in 'webhookHttpMethods' for a channel type + will be treated as an incoming message */ + if (messageMethods.includes(req.method)) { + await WebhookController.forwardMessage(req, res, channel) + return + } + + /* Requests made on other methods will be treated as a subscription request */ + try { + await WebhookController.subscribeWebhook(req, res, channel) + } catch (e) { + throw new BadRequestError('Unimplemented service method') + } + + } + + static async forwardMessage (req, res, channel) { + if (!channel.isActivated) { + throw new BadRequestError('Channel is not activated') + } + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + await channelIntegration.authenticateWebhookRequest(req, res, channel) + channel = await channelIntegration.onWebhookCalled(req, res, channel) + const context = await channelIntegration.populateMessageContext(req, res, channel) + const message = channelIntegration.getRawMessage(channel, req, context) + const pipe = new MessagePipe(channel, context) + const botResponse = await pipe.run(message) + channelIntegration.finalizeWebhookRequest(req, res, context, botResponse) + if (!res.finished) { + const warning + = `${channel.type} channel did not finalize webhook request. Sending default response` + logger.warning(warning) + res.status(200).json({ results: null, message: 'Message successfully received' }) + } + } + + static async subscribeWebhook (req, res, channel) { + const channelIntegration = getChannelIntegrationByIdentifier(channel.type) + return channelIntegration.validateWebhookSubscriptionRequest(req, res, channel) + } + + static async createConversation (req, res) { + const authorization = req.headers.authorization + const channel_id = req.params.channel_id + const channel = await Channel.findById(channel_id) + .populate('connector').exec() + + if (!channel) { throw new NotFoundError('Channel') } + if (!['webchat', 'recastwebchat'].includes(channel.type)) { + throw new BadRequestError('Invalid channel type') + } + if (channel.token !== authorization) { throw new ForbiddenError() } + + const conversation = await new Conversation({ + connector: channel.connector._id, + channel: channel._id, + chatId: 'tmp', + }).save() + conversation.chatId = conversation._id.toString() + + await conversation.save() + + if (channel.forwardConversationStart) { + const message = { message: { attachment: { type: 'conversation_start', content: '' } } } + const context = { + chatId: conversation.chatId, + senderId: `p-${conversation.chatId}`, + } + const pipe = new MessagePipe(channel, context) + await pipe.run(message) + } + + const result = conversation.full + result.participants = await Participant.find({ conversation: conversation._id }) + result.participants = result.participants.map(p => p.serialize) + result.messages = await Message + .find({ conversation: conversation._id, isActive: true }) + .sort('receivedAt') + result.messages = result.messages.map(m => m.serialize) + + return renderCreated(res, { + results: result, + message: 'Conversation successfully created', + }) + } + + static async getMessages (req, res) { + const authorization = req.headers.authorization + const channel_id = req.params.channel_id + const conversation_id = req.params.conversation_id + const channel = await Channel.findById(channel_id) + + if (!channel) { throw new NotFoundError('Channel') } + if (channel.token !== authorization) { throw new ForbiddenError() } + + const conversation = await Conversation + .findOne({ channel: channel_id, _id: conversation_id }) + if (!conversation) { throw new NotFoundError('Conversation') } + + const messages = await Message + .find({ conversation: conversation._id }) + .populate('participant') + .sort('receivedAt') + + return renderOk(res, { + message: 'Messages successfully fetched', + results: messages.map(m => m.serialize), + }) + } + + static async poll (req, res) { + const authorization = req.headers.authorization + const channel_id = req.params.channel_id + const conversation_id = req.params.conversation_id + const last_message_id = req.query.last_message_id + const channel = await Channel.findById(channel_id) + + if (!channel) { throw new NotFoundError('Channel') } + if (!['webchat', 'recastwebchat'].includes(channel.type)) { + throw new BadRequestError('Invalid channel type') + } + if (channel.token !== authorization) { throw new ForbiddenError() } + + const conversation + = await Conversation.findOne({ channel: channel_id, _id: conversation_id }) + if (!conversation) { throw new NotFoundError('Conversation') } + + let since = conversation.createdAt - 1 + if (last_message_id) { + const lastMessage = await Message.findOne({ + conversation: conversation._id, + _id: last_message_id, + }) + if (!lastMessage) { throw new NotFoundError('Message') } + since = lastMessage.receivedAt + } + + WebhookController.watchConversation(req, res, conversation._id, since) + } + + static closeRequestClosure (res, convId, watcherId) { + let closed = false + return (messages, waitTime) => { + if (closed) { return } + closed = true + waitTime = waitTime ? waitTime : 0 + renderPolledMessages(res, messages, waitTime) + messageQueue.removeWatcher(convId, watcherId) + } + } + + static async watchConversation (req, res, convId, since) { + const watcherId = uuidV4() + const closeRequest = WebhookController.closeRequestClosure(res, convId, watcherId) + req.once('close', () => closeRequest([])) + setTimeout(() => closeRequest([]), 30 * 1000) + messageQueue.setWatcher(convId, watcherId, closeRequest) + // important to query the db after we set a watcher, otherwise we might miss messages + const newMessages = await Message + .find({ conversation: convId, receivedAt: { $gt: since } }) + .sort('receivedAt') + .populate('participant') + if (newMessages.length > 0) { return closeRequest(newMessages.map(m => m.serialize)) } + // more than two minutes ago, wait for two minutes + if (Date.now() - since > 2 * 60 * 1000) { + return closeRequest([], 120) + } + } + + static async getPreferences (req, res) { + const authorization = req.headers.authorization + const channel_id = req.params.channel_id + const channel = await Channel.findById(channel_id) + + if (!channel) { throw new NotFoundError('Channel') } + if (!['webchat', 'recastwebchat'].includes(channel.type)) { + throw new BadRequestError('Invalid channel type') + } + if (channel.token !== authorization) { throw new ForbiddenError() } + + const preferences = { + accentColor: channel.accentColor, + complementaryColor: channel.complementaryColor, + botMessageColor: channel.botMessageColor, + botMessageBackgroundColor: channel.botMessageBackgroundColor, + backgroundColor: channel.backgroundColor, + headerLogo: channel.headerLogo, + headerTitle: channel.headerTitle, + botPicture: channel.botPicture, + userPicture: channel.userPicture, + onboardingMessage: channel.onboardingMessage, + userInputPlaceholder: channel.userInputPlaceholder, + expanderLogo: channel.expanderLogo, + expanderTitle: channel.expanderTitle, + conversationTimeToLive: channel.conversationTimeToLive, + openingType: channel.openingType, + welcomeMessage: channel.welcomeMessage, + characterLimit: channel.characterLimit, + } + + try { + const locale = channel.webchatLocale + preferences.menu = locale + ? await PersistentMenu.findOne({ connector_id: channel.connector.id, locale }) + : await PersistentMenu.findOne({ connector_id: channel.connector.id, default: true }) + } catch (err) { + console.log('preferences', err) // eslint-disable-line + } + + renderOk(res, { + message: 'Preferences successfully rendered', + results: preferences, + }) + } + + static sendMessageToBot ([conversation, message, memoryOptions, context]) { + // Don't send a message to the bot if the message is empty (Nil) + if (_.isEmpty(_.get(message, 'attachment.content', null))) { + return new Promise((resolve) => resolve({})) + } + + const participantData = context.participantData + const { chatId, senderId, mentioned } = context + const origin = conversation.channel.type + + if (participantData) { + message.data = participantData + } + + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + } + + return request.post(conversation.connector.url) + .set(headers) + .send({ + message, + chatId, + senderId, + mentioned, + origin, + memory: memoryOptions.memory, + merge_memory: memoryOptions.merge, + }) + .then((response) => response.body) + } + +} diff --git a/src/index.js b/src/index.js index 3dae151..e53b860 100644 --- a/src/index.js +++ b/src/index.js @@ -1,71 +1,7 @@ -import express from 'express' -import mongoose from 'mongoose' -import bodyParser from 'body-parser' -import _ from 'lodash' +import { logger } from './utils' +import { startApplication } from './app' +import config from '../config' -import configs from '../config' -import { Logger } from './utils' - -const app = express() - -// Load the mongoose Schemas - -import _models from './models' -import _controllers from './controllers' -import _services from './services' - -global.models = _models -global.controllers = _controllers -global.services = {} - -_.forOwn(_services, (service, serviceName) => { - services[serviceName.toLowerCase()] = service -}) - -const createRouter = require('./routes').createRouter - -// Load the configuration -global.config = configs - -// Enable Cross Origin Resource Sharing -app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Headers', '*, X-Expiry, X-Client, X-Access-Token, X-Uuid, Content-Type, Authorization') - res.header('Access-Control-Expose-Headers', 'X-Client, X-Access-Token, X-Expiry, X-Uuid') - res.header('Access-Control-Allow-Methods', 'GET, DELETE, POST, PUT, OPTIONS') - res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate') - res.header('Expires', '-1') - res.header('Pragma', 'no-cache') - next() -}) - -// Enable auto parsing of json content -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: true })) - -// Use native promise API with mongoose -mongoose.Promise = global.Promise - -// Mongoose connection -let dbUrl = 'mongodb://' - -if (config.db.username) { - dbUrl = `${dbUrl}${config.db.username}:${config.db.password}@` -} -dbUrl = `${dbUrl}${config.db.host}:${config.db.port}/${config.db.dbName}?ssl=${config.db.ssl || 'false'}` - -mongoose.connect(dbUrl) -const db = mongoose.connection -db.on('error', err => { - Logger.error('FAILED TO CONNECT', err) - process.exit(1) -}) - -// Launch the application -db.once('open', () => { - createRouter(app) - app.listen(config.server.port, () => { - app.emit('ready') - Logger.info(`App is running and listening to port ${config.server.port}`) - }) -}) +startApplication().then( + () => logger.info(`App is running and listening to port ${config.server.port}`), + (err) => logger.error(`Failed to start app: ${err}`)) diff --git a/src/models/Channel.model.js b/src/models/Channel.model.js deleted file mode 100644 index 4c194d5..0000000 --- a/src/models/Channel.model.js +++ /dev/null @@ -1,65 +0,0 @@ -import mongoose from 'mongoose' -import uuidV4 from 'uuid/v4' -import _ from 'lodash' - -import { getWebhookToken } from '../utils' - -const ChannelSchema = new mongoose.Schema({ - _id: { type: String, default: uuidV4 }, - connector: { type: String, ref: 'Connector', required: true }, - slug: { type: String, required: true }, - type: { type: String, required: true }, - isErrored: { type: Boolean, required: true, default: false }, - isActivated: { type: Boolean, required: true, default: true }, - - token: String, - clientAppId: String, - clientId: String, - clientSecret: String, - consumerKey: String, - consumerSecret: String, - accessToken: String, - accessTokenSecret: String, - botuser: String, - userName: String, - password: String, - serviceId: String, - phoneNumber: String, - apiKey: String, - webhook: String, - oAuthUrl: String, - webhookToken: String, - app: { type: String, ref: 'Channel' }, - children: [{ type: String, ref: 'Channel' }], -}, { - timestamps: true, -}) - -async function generateUUID (next) { - if (this.isNew) { - while (await models.Channel.findOne({ _id: this._id })) { - this._id = uuidV4() - } - } - next() -} - -ChannelSchema.pre('save', generateUUID) - -ChannelSchema.virtual('serialize').get(function () { - // Filter the content of the Channel to keep only the initialized field - const filteredChannel = _.pickBy(this.toObject(), (value) => value) - delete filteredChannel._id - - return { - id: this._id, - ...filteredChannel, - isActivated: this.isActivated, - isErrored: this.isErrored, - webhookToken: this.type === 'messenger' ? getWebhookToken(this._id, this.slug) : this.webhookToken, - } -}) - -const Channel = mongoose.model('Channel', ChannelSchema) - -module.exports = Channel diff --git a/src/models/channel.js b/src/models/channel.js new file mode 100644 index 0000000..5abb5f5 --- /dev/null +++ b/src/models/channel.js @@ -0,0 +1,145 @@ +import _ from 'lodash' +import uuidV4 from 'uuid/v4' +import mongoose from 'mongoose' + +import { getWebhookToken } from '../utils' +import slug from 'slug' + +export const slugify = str => slug(str, { lower: true, replacement: '-' }) + +const ChannelSchema = new mongoose.Schema({ + _id: { type: String, default: uuidV4 }, + connector: { type: String, ref: 'Connector', required: true }, + slug: { type: String, required: true }, + type: { type: String, required: true }, + isErrored: { type: Boolean, required: true, default: false }, + isActive: { type: Boolean, required: true, default: true }, + isActivated: { type: Boolean, required: true, default: true }, + forwardConversationStart: { type: Boolean, default: false }, + hasGetStarted: { type: Boolean, default: false }, + + token: String, + clientAppId: String, + clientId: String, + clientSecret: String, + consumerKey: String, + consumerSecret: String, + accessToken: String, + accessTokenSecret: String, + envName: String, + bearerToken: String, + botuser: String, + userName: String, + password: String, + serviceId: String, + phoneNumber: String, + apiKey: String, + webhook: String, + oAuthUrl: String, + webhookToken: String, + refreshToken: String, + app: { type: String, ref: 'Channel' }, + children: [{ type: String, ref: 'Channel' }], + + /* + * Fields used for the Webchat channel configuration + */ + accentColor: String, + webchatLocale: { type: String }, + complementaryColor: String, + botMessageColor: String, + botMessageBackgroundColor: String, + backgroundColor: String, + headerLogo: String, + headerTitle: String, + botPicture: String, + userPicture: String, + onboardingMessage: String, + expanderLogo: String, + expanderTitle: String, + conversationTimeToLive: Number, + welcomeMessage: String, + openingType: { type: String, enum: ['memory', 'never', 'always'], default: 'never' }, + characterLimit: Number, + // if channel type is webchat and there's nothing (NOT if value is empty!), set to empty string + userInputPlaceholder: String, + socketId: String, + + /* + * Fields used for Amazon Alexa integration + */ + oAuthCode: String, + oAuthTokens: Object, + invocationName: String, + vendor: String, + skillId: String, + locales: [String], +}, { + timestamps: true, + usePushEach: true, +}) + +async function generateUUID (next) { + if (this.isNew) { + while (await ChannelModel.findOne({ _id: this._id })) { + this._doc._id = uuidV4() + } + } + next() +} + +function slugifyName (next) { + this._doc.slug = slugify(this._doc.slug) + next() +} + +const DEFAULT_INPUT_PLACEHOLDER = 'Write a reply' + +function addUserInputPlaceholder (next) { + const isWebchat = ['webchat', 'recastwebchat'].includes(this.type) + if (isWebchat && (typeof this.userInputPlaceholder === 'undefined')) { + this.userInputPlaceholder = DEFAULT_INPUT_PLACEHOLDER + } + next() +} + +ChannelSchema.pre('save', generateUUID) +ChannelSchema.pre('save', slugifyName) +ChannelSchema.pre('save', addUserInputPlaceholder) + +ChannelSchema.virtual('serialize').get(function () { + const filteredChannel = _.pickBy(this.toObject(), (value, key) => { + return value !== undefined && !['_id', '__v', 'isActive'].includes(key) + }) + + // Cannot serialize children as they're not Mongoose object anymore + // due to this.toObject + if (filteredChannel.type === 'slackapp') { + filteredChannel.children = filteredChannel.children.map(c => { + return typeof c === 'string' + ? c + : { + createdAt: c.createdAt, + slug: c.slug, + botuser: c.botuser, + token: c.token, + } + }) + } else { + delete filteredChannel.children + } + + return { + id: this._id, + ...filteredChannel, + isErrored: this.isErrored, + isActivated: this.isActivated, + webhookToken: this.type === 'messenger' + ? getWebhookToken(this._id, this.slug) : this.webhookToken, + hasGetStarted: this.hasGetStarted, + } +}) + +ChannelSchema.index({ connector: 1 }) +const ChannelModel = mongoose.model('Channel', ChannelSchema) +export default ChannelModel diff --git a/src/models/Connector.model.js b/src/models/connector.js similarity index 64% rename from src/models/Connector.model.js rename to src/models/connector.js index a95ffe6..814a832 100644 --- a/src/models/Connector.model.js +++ b/src/models/connector.js @@ -1,26 +1,17 @@ -import mongoose from 'mongoose' import uuidV4 from 'uuid/v4' +import mongoose from 'mongoose' const ConnectorSchema = new mongoose.Schema({ _id: { type: String, default: uuidV4 }, url: { type: String, required: true }, - channels: [{ type: String, ref: 'Channel' }], - conversations: [{ type: String, ref: 'Conversation' }], + isActive: { type: Boolean, required: true, default: true }, isTyping: { type: Boolean, required: true, default: true }, + defaultDelay: { type: Number, min: 0, max: 5 }, }, { usePushEach: true, timestamps: true, }) -async function generateUUID (next) { - if (this.isNew) { - while (await models.Connector.findOne({ _id: this._id })) { - this._id = uuidV4() - } - } - next() -} - ConnectorSchema .pre('save', generateUUID) @@ -29,8 +20,7 @@ ConnectorSchema.virtual('serialize').get(function () { id: this._id, url: this.url, isTyping: this.isTyping, - conversations: this.conversations, - channels: this.channels.map(c => c.serialize || c), + defaultDelay: this.defaultDelay, } }) @@ -41,6 +31,15 @@ ConnectorSchema.virtual('lightSerialize').get(function () { } }) -const Connector = mongoose.model('Connector', ConnectorSchema) +const ConnectorModel = mongoose.model('Connector', ConnectorSchema) + +async function generateUUID (next) { + if (this.isNew) { + while (await ConnectorModel.findOne({ _id: this._id })) { + this._doc._id = uuidV4() + } + } + next() +} -module.exports = Connector +export default ConnectorModel diff --git a/src/models/Conversation.model.js b/src/models/conversation.js similarity index 64% rename from src/models/Conversation.model.js rename to src/models/conversation.js index 65e3330..41f783a 100644 --- a/src/models/Conversation.model.js +++ b/src/models/conversation.js @@ -1,13 +1,16 @@ -import mongoose from 'mongoose' import uuidV4 from 'uuid/v4' +import mongoose from 'mongoose' const ConversationSchema = new mongoose.Schema({ _id: { type: String, default: uuidV4 }, channel: { type: String, ref: 'Channel', required: true }, connector: { type: String, ref: 'Connector', required: true }, chatId: { type: String, required: true }, - participants: [{ type: String, ref: 'Participant' }], - messages: [{ type: String, ref: 'Message' }], + isActive: { type: Boolean, required: true, default: true }, + + microsoftAddress: Object, + socketId: String, + replyToken: String, }, { usePushEach: true, timestamps: true, @@ -15,8 +18,8 @@ const ConversationSchema = new mongoose.Schema({ async function generateUUID (next) { if (this.isNew) { - while (await models.Conversation.findOne({ _id: this._id })) { - this._id = uuidV4() + while (await ConversationModel.findOne({ _id: this._id })) { + this._doc._id = uuidV4() } } next() @@ -39,11 +42,11 @@ ConversationSchema.virtual('full').get(function () { connector: this.connector, chatId: this.chatId, channel: this.channel, - participants: this.participants.map(p => p.serialize), - messages: this.messages.map(m => m.serialize), } }) -const Conversation = mongoose.model('Conversation', ConversationSchema) - -module.exports = Conversation +ConversationSchema.index({ channel: 1 }) +ConversationSchema.index({ connector: 1 }) +ConversationSchema.index({ chatId: 1 }) +const ConversationModel = mongoose.model('Conversation', ConversationSchema) +export default ConversationModel diff --git a/src/models/get_started_button.js b/src/models/get_started_button.js new file mode 100644 index 0000000..161a233 --- /dev/null +++ b/src/models/get_started_button.js @@ -0,0 +1,21 @@ +import uuidV4 from 'uuid/v4' +import mongoose from 'mongoose' + +const GetStartedButtonSchema = new mongoose.Schema({ + _id: { type: String, default: uuidV4 }, + channel_id: { type: String, required: true, unique: true }, + value: String, +}, { + timestamps: true, +}) + +GetStartedButtonSchema.index({ channel_id: 1 }) + +GetStartedButtonSchema.virtual('serialize').get(function () { + return { + value: this.value, + } +}) + +const GetStartedButtonModel = mongoose.model('GetStartedButton', GetStartedButtonSchema) +export default GetStartedButtonModel diff --git a/src/models/index.js b/src/models/index.js index 41bdb09..2c2b524 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -1,13 +1,7 @@ -import Connector from './Connector.model' -import Channel from './Channel.model' -import Message from './Message.model' -import Participant from './Participant.model' -import Conversation from './Conversation.model' - -export default { - Connector, - Channel, - Message, - Participant, - Conversation, -} +export Connector from './connector' +export Channel from './channel' +export Message from './message' +export Participant from './participant' +export Conversation from './conversation' +export GetStartedButton from './get_started_button' +export PersistentMenu from './persistent_menu' diff --git a/src/models/Message.model.js b/src/models/message.js similarity index 69% rename from src/models/Message.model.js rename to src/models/message.js index 1f19fdb..8d92690 100644 --- a/src/models/Message.model.js +++ b/src/models/message.js @@ -6,13 +6,16 @@ const MessageSchema = new mongoose.Schema({ attachment: { type: Object }, participant: { type: String, ref: 'Participant', required: true }, conversation: { type: String, ref: 'Conversation', required: true }, - receivedAt: { type: Date, default: Date.now() }, + isActive: { type: Boolean, required: true, default: true }, + data: Object, + delay: { type: Number }, + receivedAt: { type: Date, default: Date.now }, }) async function generateUUID (next) { if (this.isNew) { - while (await models.Message.findOne({ _id: this._id })) { - this._id = uuidV4() + while (await Message.findOne({ _id: this._id })) { + this._doc._id = uuidV4() } } next() @@ -29,6 +32,6 @@ MessageSchema.virtual('serialize').get(function () { } }) +MessageSchema.index({ conversation: 1 }) const Message = mongoose.model('Message', MessageSchema) - -module.exports = Message +export default Message diff --git a/src/models/Participant.model.js b/src/models/participant.js similarity index 54% rename from src/models/Participant.model.js rename to src/models/participant.js index 04b907e..0a5c019 100644 --- a/src/models/Participant.model.js +++ b/src/models/participant.js @@ -3,21 +3,15 @@ import uuidV4 from 'uuid/v4' const ParticipantSchema = new mongoose.Schema({ _id: { type: String, default: uuidV4 }, + conversation: { type: String, required: true }, senderId: String, + data: { type: Object }, isBot: { type: Boolean, default: false }, + type: String, // One of 'user', 'bot' or 'agent' }, { timestamps: true, }) -async function generateUUID (next) { - if (this.isNew) { - while (await models.Participant.findOne({ _id: this._id })) { - this._id = uuidV4() - } - } - next() -} - ParticipantSchema.pre('save', generateUUID) ParticipantSchema.virtual('serialize').get(function () { @@ -28,6 +22,26 @@ ParticipantSchema.virtual('serialize').get(function () { } }) +ParticipantSchema.virtual('adminSerialize').get(function () { + return { + id: this._id, + data: this.data, + isBot: this.isBot, + senderId: this.senderId, + type: this.type, + } +}) + +ParticipantSchema.index({ conversation: 1 }) const Participant = mongoose.model('Participant', ParticipantSchema) -module.exports = Participant +async function generateUUID (next) { + if (this.isNew) { + while (await Participant.findOne({ _id: this._id })) { + this._doc._id = uuidV4() + } + } + next() +} + +export default Participant diff --git a/src/models/persistent_menu.js b/src/models/persistent_menu.js new file mode 100644 index 0000000..c0d86bf --- /dev/null +++ b/src/models/persistent_menu.js @@ -0,0 +1,26 @@ +import uuidV4 from 'uuid/v4' +import mongoose from 'mongoose' + +const PersistentMenuSchema = new mongoose.Schema({ + _id: { type: String, default: uuidV4 }, + connector_id: { type: String, required: true }, + menu: { type: mongoose.Schema.Types.Mixed, required: true }, + default: { type: Boolean, default: false, required: true }, + locale: { type: String, required: true }, +}, { + timestamps: true, +}) + +PersistentMenuSchema.index({ connector_id: 1, locale: 1 }, { unique: true }) +PersistentMenuSchema.index({ connector_id: 1 }) + +PersistentMenuSchema.virtual('serialize').get(function () { + return { + menu: this.menu, + default: this.default, + locale: this.locale, + } +}) + +const PersistentMenuModel = mongoose.model('PersistentMenu', PersistentMenuSchema) +export default PersistentMenuModel diff --git a/src/routes/App.routes.js b/src/routes/App.routes.js deleted file mode 100644 index c8fb19d..0000000 --- a/src/routes/App.routes.js +++ /dev/null @@ -1,15 +0,0 @@ -export default [ - { - method: 'GET', - path: '/', - validators: [], - handler: controllers.App.index, - }, - - { - method: 'POST', - path: '/', - validators: [], - handler: controllers.App.index, - }, -] diff --git a/src/routes/Channels.routes.js b/src/routes/Channels.routes.js deleted file mode 100644 index 3087565..0000000 --- a/src/routes/Channels.routes.js +++ /dev/null @@ -1,172 +0,0 @@ -import * as channelValidators from '../validators/Channels.validators.js' - -export default [ - - /** - * @api {post} /bots/:bot_id/channels Create a Channel - * @apiName createChannelByConnectorId - * @apiGroup Channel - * - * @apiDescription Create a channel only with parameters provided. - * - * @apiParam {String} slug Channel slug - * @apiParam {String} type Channel type - * @apiParam {String} token (Optionnal) Channel token (Messenger and Slack) - * @apiParam {String} userName (Optionnal) Channel username (Kik) - * @apiParam {String} apiKey (Optionnal) Channel apiKey (Kik and Messenger) - * @apiParam {String} webhook (Optionnal) Channel webhook (Kik and Messenger) - * @apiParam {Boolean} isActivated Channel isActivated - * - * @apiSuccess {Object} results Channel information - * @apiSuccess {String} results.slug Channel slug - * @apiSuccess {String} results.type Channel type - * @apiSuccess {String} results.token Channel token - * @apiSuccess {String} results.userName Channel userName - * @apiSuccess {String} results.apiKey Channel apiKey - * @apiSuccess {String} results.webhook Channel webhook - * @apiSuccess {Boolean} results.isActivated Channel isActivated - * @apiSuccess {String} message success message - * - * @apiError (Bad Request 400 bot_id not valid) {String} message Return if bot_id is invalid - * - * @apiError (Bad Request 400 type not valid) {String} message Return if type is invalid - * - * @apiError (Bad Request 400 isActivated not valid) {String} message Return if isActivated is invalid - * - * @apiError (Bad Request 400 slug not valid) {String} message Return if slug is invalid - * - * @apiError (Conflict 409 slug already taken) {String} message Return if slug is already taken in the current Connector scope - */ - { - method: 'POST', - path: '/connectors/:connector_id/channels', - validators: [channelValidators.create], - handler: controllers.Channels.create, - }, - - /** - * @api {get} /bots/:bot_id/channels Get Channels - * @apiName getChannelsByConnectorId - * @apiGroup Channel - * - * @apiDescription Get all Channels of a Connector - * - * @apiParam {String} bot_id Connector id - * - * @apiSuccess {Array} results Array of Channels - * @apiSuccess {String} results.bot Connector object - * @apiSuccess {String} results.slug Channel slug - * @apiSuccess {String} results.type Channel type - * @apiSuccess {String} results.token Channel token - * @apiSuccess {String} results.userName Channel userName - * @apiSuccess {String} results.apiKey Channel apiKey - * @apiSuccess {String} results.webhook Channel webhook - * @apiSuccess {Boolean} results.isActivated Channel isActivated - * @apiSuccess {String} message success message - * - * @apiError (Bad Request 400 bot_id not valid) {String} message Return if bot_id is invalid - * - * @apiError (Not found 404) {String} message Return if the Connector doesn't exist - */ - { - method: 'GET', - path: '/connectors/:connector_id/channels', - validators: [], - handler: controllers.Channels.index, - }, - - /** - * @api {get} /bots/:bot_id/channels/:channel_slug Get a Channel - * @apiName getChannelByConnectorId - * @apiGroup Channel - * - * @apiDescription Get a Channel of a Connector - * - * @apiParam {String} channel_slug Channel slug. - * - * @apiSuccess {Object} results Channel information - * @apiSuccess {String} results.channel_slug Channel slug. - * @apiSuccess {String} results.bot Connector object. - * @apiSuccess {String} results.slug Channel slug. - * @apiSuccess {String} results.type Channel type. - * @apiSuccess {String} results.token Channel token. - * @apiSuccess {String} results.userName Channel userName. - * @apiSuccess {String} results.apiKey Channel apiKey. - * @apiSuccess {String} results.webhook Channel webhook. - * @apiSuccess {Boolean} results.isActivated Channel isActivated. - * @apiSuccess {String} message success message - * - * @apiError (Bad Request 400 bot_id not valid) {String} message Return if bot_id is invalid - * - * @apiError (Bad Request 400 channel_slug not valid) {String} message Return if channel_slug is invalid - * - * @apiError (Not found 404) {String} message Return if either the Connector or the Channel doesn't exist - */ - { - method: 'GET', - path: '/connectors/:connector_id/channels/:channel_slug', - validators: [], - handler: controllers.Channels.show, - }, - - /** - * @api {put} /bots/:bot_id/channels/:channel_slug Update a Channel - * @apiName updateChannelByConnectorId - * @apiGroup Channel - * - * @apiDescription Update a Channel - * - * @apiParam {String} channel_slug Channel slug. - * @apiParam {String} slug Channel slug - * @apiParam {String} type Channel type - * @apiParam {String} token (Optionnal) Channel token (Messenger and Slack) - * @apiParam {String} userName (Optionnal) Channel username (Kik) - * @apiParam {String} apiKey (Optionnal) Channel apiKey (Kik and Messenger) - * @apiParam {String} webhook (Optionnal) Channel webhook (Kik and Messenger) - * @apiParam {Boolean} isActivated Channel isActivated - * - * @apiSuccess {Object} results Channel information - * @apiSuccess {String} results.channel_slug Channel slug. - * @apiSuccess {String} results.bot Connector object. - * @apiSuccess {String} results.slug Channel slug. - * @apiSuccess {String} results.type Channel type. - * @apiSuccess {String} results.token Channel token. - * @apiSuccess {String} results.userName Channel userName. - * @apiSuccess {String} results.apiKey Channel apiKey. - * @apiSuccess {String} results.webhook Channel webhook. - * @apiSuccess {Boolean} results.isActivated Channel isActivated. - * @apiSuccess {String} message success message - * - * @apiError (Bad Request 400 bot_id not valid) {String} message Return if bot_id is invalid - * - * @apiError (Bad Request 400 channel_slug not valid) {String} message Return if channel_slug is invalid - * - * @apiError (Not found 404) {String} message Return if either the Connector or the Channel doesn't exist - * - * @apiError (Conflict 409 slug already taken) {String} message Return if slug is already taken - */ - { - method: 'PUT', - path: '/connectors/:connector_id/channels/:channel_slug', - validators: [], - handler: controllers.Channels.update, - }, - - /** - * @api {delete} /bots/:bot_id/channels/:channel_slug Delete a Channel - * @apiName deleteChannelByConnectorId - * @apiGroup Channel - * - * @apiDescription Delete a Channel - * - * @apiParam {String} channel_slug Channel slug. - * - * @apiError (Not found 404) {String} message Return if either the Connector or the Channel doesn't exist - */ - { - method: 'DELETE', - path: '/connectors/:connector_id/channels/:channel_slug', - validators: [], - handler: controllers.Channels.delete, - }, -] diff --git a/src/routes/Connectors.routes.js b/src/routes/Connectors.routes.js deleted file mode 100644 index bf9f0be..0000000 --- a/src/routes/Connectors.routes.js +++ /dev/null @@ -1,110 +0,0 @@ -import * as connectorValidators from '../validators/Connectors.validators.js' - -export default [ - -/** -* @api {GET} /connectors/:bot_id Get a connector by botId -* @apiName getConnectorByBotId -* @apiGroup Connector -* -* @apiDescription Get a connector by botId -* -* @apiParam {String} bot_id BotId -* -* @apiSuccess {Object} results Connector information -* @apiSuccess {String} results.id Connector id -* @apiSuccess {String} results.url Bot url -* @apiSuccess {String} results.botId BotId -* @apiSuccess {Array} results.channels Array of Channels (see Channels) -* @apiSuccess {Array} results.conversations Array of Conversations (see Conversations) -* @apiSuccess {String} message success message -* -* @apiError (Bad Request 400 bot_id is invalid) {String} message Return if bot_id is invalid -* -* @apiError (Not found 404) {String} message Return if the connector doesn't exist -*/ - { - method: 'GET', - path: '/connectors/:connector_id', - validators: [], - handler: controllers.Connectors.show, - }, - -/** -* @api {POST} /bots Create a connector -* @apiName createConnector -* @apiGroup Connector -* -* @apiDescription Create a new connector -* -* @apiParam {String} url Bot url endpoint -* @apiParam {String} botId BotId (ref to bernard's bot) -* -* @apiSuccess {Object} results Connector information -* @apiSuccess {String} results.id Connector id -* @apiSuccess {String} results.url Bot url -* @apiSuccess {String} results.botId Bot id -* @apiSuccess {Array} results.channels Array of Channels (see Channels) -* @apiSuccess {Array} results.conversations Array of Conversations (see Conversations) -* @apiSuccess {String} message success message -* -* @apiError (Bad Request 400 url not valid) {String} message Return if url is invalid -* @apiError (Bad Request 400 url not valid) {String} message Return if botId is invalid -*/ - { - method: 'POST', - path: '/connectors', - validators: [connectorValidators.createConnector], - handler: controllers.Connectors.create, - }, - -/** -* @api {PUT} /connectors/:bot_id Update a connector -* @apiName updateConnectorByBotId -* @apiGroup Connector -* -* @apiDescription Update a connector -* -* @apiParam {String} bot_id Bot id -* @apiParam {String} url Bot new url endpoint -* -* @apiSuccess {Object} results Connector information -* @apiSuccess {String} results.id Connector id -* @apiSuccess {String} results.url Bot url endpoint -* @apiSuccess {String} results.botId Bot id -* @apiSuccess {Array} results.channels Array of Channels (see Channels) -* @apiSuccess {Array} results.conversations Array of Conversations (see Conversations) -* @apiSuccess {String} message success message -* -* @apiError (Bad Request 400 url not valid) {String} message Return if url is invalid -* -* @apiError (Bad Request 400 bot_id not valid) {String} message Return if bot_id is invalid -* -* @apiError (Not found 404) {String} message Return if the bot doesn't exist -*/ - { - method: 'PUT', - path: '/connectors/:connector_id', - validators: [connectorValidators.updateConnectorByBotId], - handler: controllers.Connectors.update, - }, - - /** -* @api {DELETE} /connectors/:bot_id Delete a connector -* @apiName deleteConnectorByBotId -* @apiGroup Connector -* -* @apiDescription Delete a connector -* -* @apiParam {String} bot_id Bot id -* -* @apiError (Bad Request 400 bot_id not valid) {String} message Return if bot_id is invalid -* -*/ - { - method: 'DELETE', - path: '/connectors/:connector_id', - validators: [], - handler: controllers.Connectors.delete, - }, -] diff --git a/src/routes/Oauth.routes.js b/src/routes/Oauth.routes.js deleted file mode 100644 index d216c21..0000000 --- a/src/routes/Oauth.routes.js +++ /dev/null @@ -1,10 +0,0 @@ -import SlackAppService from '../services/SlackApp.service' - -export default [ - { - method: 'GET', - path: '/oauth/slack/:channel_id', - validators: [], - handler: SlackAppService.receiveOauth, - }, -] diff --git a/src/routes/Webhooks.routes.js b/src/routes/Webhooks.routes.js deleted file mode 100644 index fa5685b..0000000 --- a/src/routes/Webhooks.routes.js +++ /dev/null @@ -1,26 +0,0 @@ -export default [ - - /* - * This route is the webhook shared with a channel - * Depending on incomming request, it automatically detect which channel message is comming from. - * In many cases, this webhook is automatically registered onto right channel (Kik for example). - * Check our documentation for more info. - */ - { - method: 'POST', - path: '/webhook/:channel_id', - validators: [], - handler: controllers.Webhooks.forwardMessage, - }, - - /* - * This route is a specific Facebook endpoint. - * Facebook needs a GET endpoint on same route as webhook one to validate this webhook - */ - { - method: 'GET', - path: '/webhook/:channel_id', - validators: [], - handler: controllers.Webhooks.subscribeWebhook, - }, -] diff --git a/src/routes/application.js b/src/routes/application.js new file mode 100644 index 0000000..ad143dd --- /dev/null +++ b/src/routes/application.js @@ -0,0 +1,18 @@ +import AppController from '../controllers/application' + +export default [ + { + method: 'GET', + path: ['/'], + validators: [], + authenticators: [], + handler: AppController.index, + }, + { + method: 'POST', + path: ['/'], + validators: [], + authenticators: [], + handler: AppController.index, + }, +] diff --git a/src/routes/channels.js b/src/routes/channels.js new file mode 100644 index 0000000..3d11737 --- /dev/null +++ b/src/routes/channels.js @@ -0,0 +1,197 @@ +import * as channelValidators from '../validators/channels' +import ChannelController from '../controllers/channels' + +export default [ + + /** + * @api {post} /connectors/:connectorId/channels Create a Channel + * @apiName createChannelByConnectorId + * @apiGroup Channel + * @apiVersion 1.0.0 + * + * @apiDescription Creates a channel only with parameters provided. + * + * @apiParam {String} connectorId Connector id + * @apiParam {String} slug Channel slug + * @apiParam {String} type Channel type + * @apiParam {String} [token] Channel token (Messenger and Slack) + * @apiParam {String} [userName] Channel username (Kik) + * @apiParam {String} [apiKey] Channel apiKey (Kik and Messenger) + * @apiParam {String} [clientId] + * @apiParam {String} [clientSecret] + * @apiParam {Boolean} isActivated Channel isActivated + * + * @apiSuccess {Object} results Channel information + * @apiSuccess {String} results.slug Channel slug + * @apiSuccess {String} results.type Channel type + * @apiSuccess {String} [results.token] Channel token, only present if a token has been set + * @apiSuccess {String} [results.userName] Channel userName, only present if a username has been set + * @apiSuccess {String} [results.apiKey] Channel apiKey, only present if an api key has been set + * @apiSuccess {String} [clientId] + * @apiSuccess {String} [clientSecret] + * @apiSuccess {String} results.webhook Channel webhook + * @apiSuccess {Boolean} results.isActivated Channel isActivated + * @apiSuccess {String} message success message + * + * @apiError (400 Bad Request - type not valid) {String} message Returned if type is invalid + * + * @apiError (400 Bad Request - isActivated not valid) {String} message Return if isActivated is invalid + * + * @apiError (400 Bad Request - slug missing) {String} message "Parameter slug is missing" + * + * @apiError (409 Conflict - slug already taken) {String} message Returned if slug is already taken in the current Connector scope + * @apiError (409 Conflict - slug already taken) {null} results null + */ + { + method: 'POST', + path: ['/connectors/:connectorId/channels'], + validators: [channelValidators.createChannelByConnectorId], + authenticators: [], + handler: ChannelController.create, + }, + + /** + * @api {get} /connectors/:connectorId/channels Get Channels + * @apiName getChannelsByConnectorId + * @apiGroup Channel + * @apiVersion 1.0.0 + * + * @apiDescription Get all Channels of a Connector + * + * @apiParam {String} connectorId Connector id + * + * @apiSuccess {Array} results Array of Channels + * @apiSuccess {String} results.connector Connector object + * @apiSuccess {String} results.slug Channel slug + * @apiSuccess {String} results.type Channel type + * @apiSuccess {String} [results.token] Channel token + * @apiSuccess {String} [results.userName] Channel userName + * @apiSuccess {String} [results.apiKey] Channel apiKey + * @apiSuccess {String} [results.clientId] clientId of the channel + * @apiSuccess {String} [results.clientSecret] clientSecret of the channel + * @apiSuccess {String} results.webhook Channel webhook + * @apiSuccess {Boolean} results.isActivated if false, incoming messages won't be forwarded to the bot + * @apiSuccess {String} message success message + * + * @apiError (404 Not Found) {String} message "Connector not found" + * @apiError (404 Not Found) {null} results null + */ + { + method: 'GET', + path: ['/connectors/:connectorId/channels'], + validators: [], + authenticators: [], + handler: ChannelController.index, + }, + + /** + * @api {get} /connectors/:connectorId/channels/:channel_slug Get a Channel + * @apiName getChannelByConnectorId + * @apiGroup Channel + * @apiVersion 1.0.0 + * + * @apiDescription Get a Channel of a Connector + * + * @apiParam {String} channel_slug Channel slug. + * + * @apiSuccess {Object} results Channel information + * @apiSuccess {String} results.channel_slug Channel slug. + * @apiSuccess {String} results.connector Connector object. + * @apiSuccess {String} results.slug Channel slug. + * @apiSuccess {String} results.type Channel type. + * @apiSuccess {String} [results.token] Channel token. + * @apiSuccess {String} [results.userName] Channel userName. + * @apiSuccess {String} [results.apiKey] Channel apiKey. + * @apiSuccess {String} [results.clientId] clientId of the channel + * @apiSuccess {String} [results.clientSecret] clientSecret of the channel + * @apiSuccess {String} results.webhook Channel webhook. + * @apiSuccess {Boolean} results.isActivated Channel isActivated. + * @apiSuccess {String} message success message + * + * @apiError (400 Bad Request - connectorId not valid) {String} message Return if connectorId is invalid + * + * @apiError (400 Bad Request - channel_slug not valid) {String} message Return if channel_slug is invalid + * + * @apiError (404 Not Found) {String} message Return if either the Connector or the Channel doesn't exist + */ + { + method: 'GET', + path: ['/connectors/:connectorId/channels/:channel_slug'], + validators: [], + authenticators: [], + handler: ChannelController.show, + }, + + /** + * @api {put} /connectors/:connectorId/channels/:channel_slug Update a Channel + * @apiName updateChannelByConnectorId + * @apiGroup Channel + * @apiVersion 1.0.0 + * + * @apiDescription Update a Channel + * + * @apiParam {String} channel_slug Channel slug. + * @apiParam {String} slug Channel slug + * @apiParam {String} type Channel type + * @apiParam {String} [token] Channel token (Messenger and Slack) + * @apiParam {String} [userName] Channel username (Kik) + * @apiParam {String} [apiKey] Channel apiKey (Kik and Messenger) + * @apiParam {String} [clientId] clientId of the channel + * @apiParam {String} [clientSecret] clientSecret of the channel + * @apiParam {String} [webhook] Channel webhook + * @apiParam {Boolean} isActivated Channel isActivated + * + * @apiSuccess {Object} results Channel information + * @apiSuccess {String} results.channel_slug Channel slug. + * @apiSuccess {String} results.bot Connector object. + * @apiSuccess {String} results.slug Channel slug. + * @apiSuccess {String} results.type Channel type. + * @apiSuccess {String} [results.token] Channel token. + * @apiSuccess {String} [results.userName] Channel userName. + * @apiSuccess {String} [results.apiKey] Channel apiKey. + * @apiSuccess {String} [results.clientId] clientId of the channel + * @apiSuccess {String} [results.clientSecret] clientSecret of the channel + * @apiSuccess {String} results.webhook Channel webhook. + * @apiSuccess {Boolean} results.isActivated Channel isActivated. + * @apiSuccess {String} message success message + * + * @apiError (400 Bad Request - connectorId not valid) {String} message Returned if connectorId is invalid + * + * @apiError (400 Bad Request - channel_slug not valid) {String} message Returned if channel_slug is invalid + * + * @apiError (400 Bad Request) channel_slug invalid The channel slug is empty or missing + * + * @apiError (400 Bad Request) connectorId not valid + * + * @apiError (404 Not Found - Connector or Channel not found) {String} message indicates whether the Connector or the Channel doesn't exist + * + * @apiError (409 Conflict - slug already taken) {String} message Returned if slug is already taken + */ + { + method: 'PUT', + path: ['/connectors/:connectorId/channels/:channel_slug'], + validators: [channelValidators.updateChannel], + authenticators: [], + handler: ChannelController.update, + }, + + /** + * @api {delete} /connectors/:connectorId/channels/:channel_slug Delete a Channel + * @apiName deleteChannelByConnectorId + * @apiGroup Channel + * @apiVersion 1.0.0 + * + * @apiDescription Delete a Channel + * + * @apiParam {String} channel_slug Slug of the Channel to be deleted + * + * @apiError (404 Not Found) {String} message Returned if either the Connector or the Channel doesn't exist + */ + { + method: 'DELETE', + path: ['/connectors/:connectorId/channels/:channel_slug'], + validators: [], + authenticators: [], + handler: ChannelController.delete, + }, +] diff --git a/src/routes/connectors.js b/src/routes/connectors.js new file mode 100644 index 0000000..707df01 --- /dev/null +++ b/src/routes/connectors.js @@ -0,0 +1,119 @@ +import * as connectorValidators from '../validators/connectors' +import Connectors from '../controllers/connectors' + +export default [ + /** + * @api {GET} /connectors/:connectorId Get a connector by Id + * @apiName getConnectorById + * @apiGroup Connector + * @apiVersion 1.0.0 + * + * @apiDescription Get a connector by Id + * + * @apiParam {String} connectorId connector id + * + * @apiSuccess {Object} results Connector information + * @apiSuccess {String} results.id Connector Id + * @apiSuccess {String} results.url Bot URL + * @apiSuccess {Boolean} results.isTyping if true, the bot is shown as typing while processing a response + * @apiSuccess {Array} results.channels Array of Channels (see Channels) + * @apiSuccess {Array} results.conversations Array of Conversations (see Conversations) + * @apiSuccess {String} message success message + * + * @apiError (404 Not Found) {String} message if the connector doesn't exist + * @apiError (404 Not Found) {String} results + */ + { + method: 'GET', + path: ['/connectors/:connectorId'], + validators: [], + authenticators: [], + handler: Connectors.show, + }, + + /** + * @api {POST} /connectors Create a connector + * @apiName createConnector + * @apiGroup Connector + * @apiVersion 1.0.0 + * + * @apiDescription Create a new connector + * + * @apiParam {String} url Bot URL endpoint + * @apiParam {Boolean} [isTyping=true] if true, the bot will be shown as typing while processing a response + * + * @apiSuccess {Object} results Connector information + * @apiSuccess {String} results.id Connector id + * @apiSuccess {String} results.url Bot url + * @apiSuccess {Boolean} results.isTyping if true, the bot is shown as typing while processing a response + * @apiSuccess {Array} results.channels Array of Channels (see Channels) + * @apiSuccess {Array} results.conversations Array of Conversations (see Conversations) + * @apiSuccess {String} message success message + * + * @apiError (400 Bad Request - url parameter missing) {String} message "Parameter url is missing" + * @apiError (400 Bad Request - url parameter missing) {null} results + */ + { + method: 'POST', + path: ['/connectors'], + validators: [connectorValidators.createConnector], + authenticators: [], + handler: Connectors.create, + }, + + /** + * @api {PUT} /connectors/:connectorId Update a connector + * @apiName updateConnectorById + * @apiGroup Connector + * @apiVersion 1.0.0 + * + * @apiDescription Update a connector + * + * @apiParam {String} connectorId connector id + * @apiParam {String} [url] Bot URL endpoint + * @apiParam {Boolean} [isTyping] if true, the bot will be shown as typing while processing a response. + * + * @apiSuccess {Object} results Connector information + * @apiSuccess {String} results.id Connector id + * @apiSuccess {String} results.url Bot url endpoint + * @apiSuccess {Array} results.channels Array of Channels (see Channels) + * @apiSuccess {Array} results.conversations Array of Conversations (see Conversations) + * @apiSuccess {String} message success message + * + * @apiError (400 Bad Request - url not valid) {String} message Return if url is invalid + * + * @apiError (404 Not Found) {String} message "Connector not found" + */ + { + method: 'PUT', + path: ['/connectors/:connectorId'], + validators: [connectorValidators.updateConnector], + authenticators: [], + handler: Connectors.update, + }, + + /** + * @api {DELETE} /connectors/:connectorId Delete a connector + * @apiName deleteConnectorById + * @apiGroup Connector + * @apiVersion 1.0.0 + * + * @apiDescription Delete a connector + * + * @apiParam {String} connectorId connector id + * + * @apiSuccess (200 OK) {String} message "Connector successfully deleted" + * @apiSuccess (200 OK) {null} results + * + * @apiError (404 Not Found) {String} message "Connector not found" + * @apiError (404 Not Found) {null} results + * + */ + { + method: 'DELETE', + path: ['/connectors/:connectorId'], + validators: [], + authenticators: [], + handler: Connectors.delete, + }, +] diff --git a/src/routes/Conversations.routes.js b/src/routes/conversations.js similarity index 50% rename from src/routes/Conversations.routes.js rename to src/routes/conversations.js index 0f8e0de..66cb8ab 100644 --- a/src/routes/Conversations.routes.js +++ b/src/routes/conversations.js @@ -1,35 +1,42 @@ -export default [ +import ConversationController from '../controllers/conversations' +export default [ /** - * @api {get} /conversations Get all Conversations of a Connector + * @api {get} /connectors/:connectorId/conversations Get all Conversations of a Connector * @apiName getConversationsByConnectorId * @apiGroup Conversation + * @apiVersion 1.0.0 * * @apiDescription List all the Conversations of a Connector * + * @apiParam {String} connectorId connector id + * * @apiSuccess {Array} results Array of Conversations * @apiSuccess {String} results.id Conversation id - * @apiSuccess {String} results.channel if of the Channel's Conversation + * @apiSuccess {String} results.channel Id of the Channel the Conversation belongs to * @apiSuccess {String} results.chatId id of the chat linked to the Conversation * @apiSuccess {String} results.connector ObjectId of the connector * @apiSuccess {String} message success message * - * @apiError (Not Found 404) {String} message Bot not found + * @apiError (404 Not Found) {String} message Connector not found */ { method: 'GET', - path: '/connectors/:connector_id/conversations', + path: ['/connectors/:connectorId/conversations'], validators: [], - handler: controllers.Conversations.index, + authenticators: [], + handler: ConversationController.index, }, /** - * @api {get} /conversation/:conversation_id Get Conversation + * @api {get} /connectors/:connectorId/conversation/:conversation_id Get Conversation * @apiName getConversationByConnectorId * @apiGroup Conversation + * @apiVersion 1.0.0 * * @apiDescription Get a Conversation * + * @apiParam {String} connectorId connector id * @apiParam {String} conversation_id Conversation id * * @apiSuccess {Object} results Conversation information @@ -41,37 +48,43 @@ export default [ * @apiSuccess {Array} results.messages Array of Messages * @apiSuccess {String} message success message * - * @apiError (Bad Request 400) {String} message Parameter conversation_id is invalid - - * @apiError (Not Found 404) {String} message Conversation not found + * @apiError (400 Bad Request) {String} message Parameter conversation_id is invalid + * @apiError (404 Not Found) {String} message Conversation not found */ - { method: 'GET', - path: '/connectors/:connector_id/conversations/:conversation_id', + path: ['/connectors/:connectorId/conversations/:conversation_id'], validators: [], - handler: controllers.Conversations.show, + authenticators: [], + handler: ConversationController.show, }, /** - * @api {delete} /conversations/:conversation_id Delete conversation + * @api {delete} /connectors/:connectorId/conversations/:conversation_id Delete conversation * @apiName deleteConversationByConnectorId * @apiGroup Conversation * * @apiDescription Delete a Connector's Conversation * + * @apiParam {String} connectorId connector id * @apiParam {String} conversation_id Conversation id * - * @apiError (Bad Request 400) {String} message Parameter conversation_id is invalid - * - * @apiError (Not Found 404) {String} message Bot or Conversation not found + * @apiError (400 Bad Request) {String} message Parameter conversation_id is invalid + * @apiError (404 Not Found) {String} message Connector or Conversation not found */ - { method: 'DELETE', - path: '/connectors/:connector_id/conversations/:conversation_id', + path: ['/connectors/:connectorId/conversations/:conversation_id'], validators: [], - handler: controllers.Conversations.delete, + authenticators: [], + handler: ConversationController.delete, }, + { + method: 'POST', + path: ['/connectors/:connectorId/conversations/dump'], + validators: [], + authenticators: [], + handler: ConversationController.dumpDelete, + }, ] diff --git a/src/routes/get_started_buttons.js b/src/routes/get_started_buttons.js new file mode 100644 index 0000000..73fe5e5 --- /dev/null +++ b/src/routes/get_started_buttons.js @@ -0,0 +1,32 @@ +import GetStartedButtonController from '../controllers/get_started_buttons' + +export default [ + { + method: 'POST', + path: ['/connectors/:connectorId/channels/:channel_id/getstartedbuttons'], + validators: [], + authenticators: [], + handler: GetStartedButtonController.create, + }, + { + method: 'GET', + path: ['/connectors/:connectorId/channels/:channel_id/getstartedbuttons'], + validators: [], + authenticators: [], + handler: GetStartedButtonController.show, + }, + { + method: 'PUT', + path: ['/connectors/:connectorId/channels/:channel_id/getstartedbuttons'], + validators: [], + authenticators: [], + handler: GetStartedButtonController.update, + }, + { + method: 'DELETE', + path: ['/connectors/:connectorId/channels/:channel_id/getstartedbuttons'], + validators: [], + authenticators: [], + handler: GetStartedButtonController.delete, + }, +] diff --git a/src/routes/index.js b/src/routes/index.js index f77b053..cda766c 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,32 +1,35 @@ import express from 'express' -import { Logger } from '../utils' -import appRoutes from './App.routes' -import oauthRoutes from './Oauth.routes' -import connectorRoutes from './Connectors.routes' -import channelRoutes from './Channels.routes' -import messagesRoutes from './Messages.routes' -import webhooksRoutes from './Webhooks.routes' -import conversationRoutes from './Conversations.routes' -import participantsRoutes from './Participants.routes' - -import { renderConnectorError } from '../utils/errors' +import appRoutes from './application' +import connectorRoutes from './connectors' +import channelRoutes from './channels' +import messageRoutes from './messages' +import webhookRoutes from './webhooks' +import conversationRoutes from './conversations' +import participantRoutes from './participants' +import getStartedButtonRoutes from './get_started_buttons' +import persistentMenuRoutes from './persistent_menus' +import { getChannelIntegrationRoutes } from '../channel_integrations' +import { renderError } from '../utils/errors' export const routes = [ ...appRoutes, - ...oauthRoutes, ...connectorRoutes, ...channelRoutes, - ...messagesRoutes, - ...webhooksRoutes, + ...messageRoutes, + ...webhookRoutes, + ...participantRoutes, ...conversationRoutes, - ...participantsRoutes, + ...getStartedButtonRoutes, + ...persistentMenuRoutes, + ...getChannelIntegrationRoutes(), ] export const createRouter = app => { const router = express.Router() routes.forEach(r => { + router[r.method.toLowerCase()](r.path, async (req, res) => { try { // Validate the request parameters @@ -34,14 +37,23 @@ export const createRouter = app => { await validator(req, res) } + // Validate the request authentication + for (const authenticator of r.authenticators) { + await authenticator(req, res) + } + await r.handler(req, res) } catch (err) { - Logger.error(err) - renderConnectorError(res, err) + renderError(res, err) } + }) + }) - app.use(router) + app.get('/', (req, res) => { + res.send('Hi!') + }) + app.use('/v1', router) } diff --git a/src/routes/Messages.routes.js b/src/routes/messages.js similarity index 81% rename from src/routes/Messages.routes.js rename to src/routes/messages.js index 849bb17..71d94a8 100644 --- a/src/routes/Messages.routes.js +++ b/src/routes/messages.js @@ -1,12 +1,14 @@ -export default [ +import MessageController from '../controllers/messages' +export default [ /** - * @api {post} /conversations/:conversation_id/messages Post a message into a specific conversation + * @api {post} /connectors/:connectorId/conversations/:conversation_id/messages Post a message into a specific conversation * @apiName Post a message to a conversation * @apiGroup Messages * * @apiDescription Post a message into a specific conversation. With this route, you do not have to only answer to an user as response of a previous message, we can also directly send him messages * + * @apiParam (Route Parameters) {ObjectId} connectorId connector id * @apiParam (Route Parameters) {ObjectId} conversation_id Conversation ObjectId * * @apiParam (Text message parameters) {String} type=text Must be 'text' in this case @@ -42,8 +44,8 @@ export default [ * @apiError (Bad Request 400 for conversation_id) {null} results Response data * @apiError (Bad Request 400 for conversation_id) {String} message Parameter conversation_id is invalid * - * @apiError (Not found 404 for bot) {null} results Response data - * @apiError (Not found 404 for bot) {String} message Bot not found + * @apiError (Not found 404 for connector) {null} results Response data + * @apiError (Not found 404 for connector) {String} message Connector not found * * @apiError (Not found 404 for conversation) {null} results Response data * @apiError (Not found 404 for conversation) {String} message Conversation not found @@ -53,17 +55,20 @@ export default [ */ { method: 'POST', - path: '/connectors/:connector_id/conversations/:conversation_id/messages', + path: ['/connectors/:connectorId/conversations/:conversationId/messages'], validators: [], - handler: controllers.Messages.postMessage, + authenticators: [], + handler: MessageController.postMessage, }, /** * @api {post} /messages Post a message to a specific bot * @apiName Post a message to a bot - * @apiGroup Bot + * @apiGroup Connector * - * @apiDescription Post a message to a specific. With this route, you do not have to only answer to an user as response of a previous message, we can also directly send him messages + * @apiDescription Post a message to a specific bot. With this route, you do not have to only answer to a user in response to a previous message, we can also directly send them messages + + * @apiParam {String} connectorId connector id * * @apiParam (Text message parameters) {String} type=text Must be 'text' in this case * @apiParam (Text message parameters) {String} value Your message @@ -95,19 +100,17 @@ export default [ * @apiSuccess (Success 200) {Participant} results.participant Message Participant * @apiSuccess (Success 200) {String} message Message successfully posted * - * @apiError (Bad Request 400 for conversation_id) {null} results Response data - * @apiError (Bad Request 400 for conversation_id) {String} message Parameter conversation_id is invalid - * - * @apiError (Not found 404 for bot) {null} results Response data - * @apiError (Not found 404 for bot) {String} message Bot not found + * @apiError (Not found 404 for connector) {null} results Response data + * @apiError (Not found 404 for connector) {String} message Connector not found * - * @apiError (Internal Server Error 500 if bot participant not found) {null} results Response data - * @apiError (Internal Server Error 500 if bot participant not found) {String} message Bot participant not found in this conversation + * @apiError (Not Found 404 if bot participant not found) {null} results Response data + * @apiError (Not Found 404 if bot participant not found) {String} message Bot participant not found in this conversation */ { method: 'POST', - path: '/connectors/:connector_id/messages', + path: ['/connectors/:connectorId/messages'], validators: [], - handler: controllers.Messages.broadcastMessage, + authenticators: [], + handler: MessageController.broadcastMessage, }, ] diff --git a/src/routes/Participants.routes.js b/src/routes/participants.js similarity index 73% rename from src/routes/Participants.routes.js rename to src/routes/participants.js index 7e79de5..6d99790 100644 --- a/src/routes/Participants.routes.js +++ b/src/routes/participants.js @@ -1,32 +1,34 @@ +import ParticipantController from '../controllers/participants' + export default [ /** - * @api {get} /participants Index Connector's Participants + * @api {get} /connectors/:connectorId/participants Index Connector's Participants * @apiName List participants * @apiGroup Participants * - * @apiDescription Index connector participants (for all conversations and all channels) + * @apiDescription List all connector participants (for all conversations and all channels) * * @apiSuccess (Success 200 with at least one participant) {Array} results Array of participants * @apiSuccess (Success 200 with at least one participant) {ObjectId} results.id Participant objectId * @apiSuccess (Success 200 with at least one participant) {String} results.name Participant name * @apiSuccess (Success 200 with at least one participant) {String} results.slug Participant slug * @apiSuccess (Success 200 with at least one participant) {Object} results.information Particpant information - * @apiSuccess (Success 200 with at least one participant) {Boolean} results.isBot Is this particpant a bot ? + * @apiSuccess (Success 200 with at least one participant) {Boolean} results.isBot Is this particpant a bot? * @apiSuccess (Success 200 with at least one participant) {String} message Participants successfully rendered * - * * @apiSuccess (Success 200 with no participant) {null} results Response data * @apiSuccess (Success 200 with no participant) {String} message No participants */ { method: 'GET', - path: '/connectors/:connector_id/participants', + path: ['/connectors/:connectorId/participants'], validators: [], - handler: controllers.Participants.index, + authenticators: [], + handler: ParticipantController.index, }, /** - * @api {get} /participants/:participant_id Show a specific participant (for a connector) + * @api {get} /connectors/:connectorId/participants/:participant_id Show a specific participant (for a connector) * @apiName Show participant * @apiGroup Participants * @@ -39,15 +41,16 @@ export default [ * @apiSuccess (Success 200) {String} results.name Participant name * @apiSuccess (Success 200) {String} results.slug Participant slug * @apiSuccess (Success 200) {Object} results.information Particpant information - * @apiSuccess (Success 200) {Boolean} results.isBot Is this particpant a bot ? + * @apiSuccess (Success 200) {Boolean} results.isBot Is this particpant a bot? * @apiSuccess (Success 200) {String} message Participant successfully rendered * * @apiError (Bad Request 400 for participant_id) {String} message Parameter participant_id is invalid */ { method: 'GET', - path: '/connectors/:connector_id/participants/:participant_id', + path: ['/connectors/:connectorId/participants/:participant_id'], validators: [], - handler: controllers.Participants.show, + authenticators: [], + handler: ParticipantController.show, }, ] diff --git a/src/routes/persistent_menus.js b/src/routes/persistent_menus.js new file mode 100644 index 0000000..b4203ce --- /dev/null +++ b/src/routes/persistent_menus.js @@ -0,0 +1,53 @@ +import PersistentMenuController from '../controllers/persistent_menus' + +export default [ + { + method: 'POST', + path: ['/connectors/:connectorId/persistentmenus'], + validators: [], + authenticators: [], + handler: PersistentMenuController.create, + }, + { + method: 'POST', + path: ['/connectors/:connectorId/persistentmenus/setdefault'], + validators: [], + authenticators: [], + handler: PersistentMenuController.setDefault, + }, + { + method: 'GET', + path: ['/connectors/:connectorId/persistentmenus/:language'], + validators: [], + authenticators: [], + handler: PersistentMenuController.show, + }, + { + method: 'GET', + path: ['/connectors/:connectorId/persistentmenus'], + validators: [], + authenticators: [], + handler: PersistentMenuController.index, + }, + { + method: 'PUT', + path: ['/connectors/:connectorId/persistentmenus/:language'], + validators: [], + authenticators: [], + handler: PersistentMenuController.update, + }, + { + method: 'DELETE', + path: ['/connectors/:connectorId/persistentmenus/:language'], + validators: [], + authenticators: [], + handler: PersistentMenuController.delete, + }, + { + method: 'DELETE', + path: ['/connectors/:connectorId/persistentmenus'], + validators: [], + authenticators: [], + handler: PersistentMenuController.deleteAll, + }, +] diff --git a/src/routes/webhooks.js b/src/routes/webhooks.js new file mode 100644 index 0000000..8d401a8 --- /dev/null +++ b/src/routes/webhooks.js @@ -0,0 +1,60 @@ +import WebhookController from '../controllers/webhooks' + +export default [ + { + method: 'POST', + path: ['/webhook/service/:channel_type'], + validators: [], + authenticators: [], + handler: WebhookController.serviceHandleMethodAction, + }, + { + method: 'GET', + path: ['/webhook/service/:channel_type'], + validators: [], + authenticators: [], + handler: WebhookController.serviceHandleMethodAction, + }, + { + method: 'POST', + path: ['/webhook/:channel_id'], + validators: [], + authenticators: [], + handler: WebhookController.handleMethodAction, + }, + { + method: 'GET', + path: ['/webhook/:channel_id'], + validators: [], + authenticators: [], + handler: WebhookController.handleMethodAction, + }, + { + method: 'POST', + path: ['/webhook/:channel_id/conversations'], + validators: [], + authenticators: [], + handler: WebhookController.createConversation, + }, + { + method: 'GET', + path: ['/webhook/:channel_id/conversations/:conversation_id/messages'], + validators: [], + authenticators: [], + handler: WebhookController.getMessages, + }, + { + method: 'GET', + path: ['/webhook/:channel_id/preferences'], + validators: [], + authenticators: [], + handler: WebhookController.getPreferences, + }, + { + method: 'GET', + path: ['/webhook/:channel_id/conversations/:conversation_id/poll'], + validators: [], + authenticators: [], + handler: WebhookController.poll, + }, +] diff --git a/src/services/Callr.service.js b/src/services/Callr.service.js deleted file mode 100644 index 62d9241..0000000 --- a/src/services/Callr.service.js +++ /dev/null @@ -1,147 +0,0 @@ -import _ from 'lodash' -import callr from 'callr' -import crypto from 'crypto' - -import { Logger } from '../utils' -import Template from './Template.service' -import { BadRequestError, ForbiddenError } from '../utils/errors' - -/* - * checkParamsValidity: ok - * onChannelCreate: ok - * onChannelUpdate: ok - * onChannelDelete: ok - * onWebhookChecking: default - * checkSecurity: ok - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - -export default class Callr extends Template { - - static checkParamsValidity (channel) { - if (!channel.password) { - throw new BadRequestError('Parameter password is missing') - } else if (!channel.userName) { - throw new BadRequestError('Parameter userName is missing') - } - } - - static async onChannelCreate (channel) { - const type = 'sms.mo' - const options = { hmac_secret: channel.password, hmac_algo: 'SHA256' } - const api = new callr.api(channel.userName, channel.password) - - try { - await new Promise((resolve, reject) => (api.call('webhooks.subscribe', type, channel.webhook, options) - .success(async (res) => { - channel.webhookToken = res.hash - resolve() - }) - .error(reject))) - channel.isErrored = false - } catch (err) { - Logger.info('[CallR] Error while setting webhook') - channel.isErrored = true - } - - return channel.save() - } - - static async onChannelUpdate (channel, oldChannel) { - await Callr.onChannelDelete(oldChannel) - await Callr.onChannelCreate(channel) - } - - static async onChannelDelete (channel) { - try { - const api = new callr.api(channel.userName, channel.password) - await new Promise((resolve, reject) => (api.call('webhooks.unsubscribe', channel.webhookToken) - .success(resolve) - .error(reject))) - } catch (err) { - Logger.info('[CallR] Error while unsetting webhook') - } - } - - static checkSecurity (req, res, channel) { - const { password } = channel - const payload = JSON.stringify(req.body) - const webhookSig = req.headers['x-callr-hmacsignature'] - const hash = crypto.createHmac('SHA256', password).update(payload).digest('base64') - - if (hash !== webhookSig) { - throw new ForbiddenError() - } - - res.status(200).send() - } - - static extractOptions (req) { - return { - chatId: _.get(req, 'body.data.from'), - senderId: _.get(req, 'body.data.to'), - } - } - - static parseChannelMessage (conversation, message, opts) { - const msg = { - attachment: { - type: 'text', - content: message.data.text, - }, - channelType: 'callr', - } - - return [conversation, msg, { ...opts, mentioned: true }] - } - - static formatMessage (conversation, message) { - const { type, content } = _.get(message, 'attachment', {}) - - switch (type) { - case 'text': - case 'picture': - case 'video': - return content - case 'quickReplies': - const { title, buttons } = content - return _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n`) - case 'card': { - const { title, subtitle, imageUrl, buttons } = content - return _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n${subtitle}\n${imageUrl}\n`) - } - case 'list': - return _.reduce(content.elements, (acc, elem) => { - return `${acc}\n\n- ${elem.title}\n${elem.subtitle}\n${elem.imageUrl}` - }, '') - case 'carousel': - case 'carouselle': - return _.reduce(content, (acc, card) => { - const { title, subtitle, imageUrl, buttons } = card - return acc + _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n${subtitle}\n${imageUrl}\n`) - }, '') - default: - throw new BadRequestError('Message type is non-supported by Callr') - } - } - - static sendMessage (conversation, message, opts) { - return new Promise(async (resolve, reject) => { - const { senderId } = opts - const { chatId, channel } = conversation - const api = new callr.api(channel.userName, channel.password) - - api.call('sms.send', senderId, chatId, message, null) - .success(resolve) - .error(reject) - }) - } - -} diff --git a/src/services/Messenger.service.js b/src/services/Messenger.service.js deleted file mode 100644 index 50540d6..0000000 --- a/src/services/Messenger.service.js +++ /dev/null @@ -1,208 +0,0 @@ -import _ from 'lodash' -import request from 'superagent' - -import Template from './Template.service' -import { getWebhookToken } from '../utils' -import { StopPipeline, BadRequestError, ForbiddenError } from '../utils/errors' - -const agent = require('superagent-promise')(require('superagent'), Promise) - -/* - * checkParamsValidity: ok - * onChannelCreate: default - * onChannelUpdate: default - * onChannelDelete: default - * onWebhookChecking: ok - * checkSecurity: default - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: ok - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - -export default class Messenger extends Template { - - static checkParamsValidity (channel) { - if (!channel.token) { - throw new BadRequestError('Parameter token is missing') - } else if (!channel.apiKey) { - throw new BadRequestError('Parameter apiKey is missing') - } - } - - static onWebhookChecking (req, res, channel) { - if (req.query['hub.mode'] === 'subscribe' && req.query['hub.verify_token'] === getWebhookToken(channel._id, channel.slug)) { - res.status(200).send(req.query['hub.challenge']) - } else { - throw new BadRequestError('Error while checking the webhook validity') - } - } - - static async checkSecurity (req, res, channel) { - if (channel.webhook.startsWith('https://'.concat(req.headers.host))) { - res.status(200).send() - } else { - throw new ForbiddenError() - } - } - - static extractOptions (req) { - const recipientId = _.get(req, 'body.entry[0].messaging[0].recipient.id') - const senderId = _.get(req, 'body.entry[0].messaging[0].sender.id') - - return { - chatId: `${recipientId}-${senderId}`, - senderId, - } - } - - static sendIsTyping (channel, options, message) { - message = _.get(message, 'entry[0].messaging[0]', {}) - - if (!message.message || (message.is_echo && message.app_id)) { - return - } - - return agent('POST', `https://graph.facebook.com/v2.6/me/messages?access_token=${channel.token}`) - .send({ - recipient: { id: options.senderId }, - sender_action: 'typing_on', - }) - } - - static async parseChannelMessage (conversation, message, opts) { - const msg = {} - message = _.get(message, 'entry[0].messaging[0]') - const type = _.get(message, 'message.attachments[0].type') - const quickReply = _.get(message, 'message.quick_reply.payload') - - if (message.account_linking) { - const { status, authorization_code } = _.get(message, 'account_linking') - msg.attachment = { type: 'account_linking', status, content: authorization_code } - } else if (message.postback) { - const content = _.get(message, 'postback.payload') - msg.attachment = { type: 'payload', content } - } else if (!message.message || (message.message.is_echo && message.message.app_id)) { - throw new StopPipeline() - } else if (type) { - const content = _.get(message, 'message.attachments[0].payload.url') - msg.attachment = { - type: type === 'image' ? 'picture' : type, - content, - } - } else if (quickReply) { - msg.attachment = { type: 'text', content: quickReply, is_button_click: true } - } else { - const content = _.get(message, 'message.text') - msg.attachment = { type: 'text', content } - } - - if (message.message && message.message.is_echo && !message.message.app_id) { - _.set(msg, 'attachment.isEcho', true) - } - - return Promise.all([conversation, msg, { ...opts, mentioned: true }]) - } - - static formatMessage (conversation, message, opts) { - const { type, content } = _.get(message, 'attachment') - const msg = { - recipient: { id: opts.senderId }, - message: {}, - } - - switch (type) { - case 'text': - _.set(msg, 'message', { text: content }) - break - case 'video': - case 'picture': - case 'audio': // Special case needed for StarWars ? - _.set(msg, 'message.attachment.type', type === 'picture' ? 'image' : type) - _.set(msg, 'message.attachment.payload.url', content) - break - case 'card': - const { title, itemUrl: item_url, imageUrl: image_url, subtitle } = _.get(message, 'attachment.content', {}) - const buttons = _.get(message, 'attachment.content.buttons', []) - .map(({ type, title, value }) => { - if (['web_url', 'account_linking'].indexOf(type) !== -1) { - return { type, title, url: value } - } else if (['postback', 'phone_number', 'element_share'].indexOf(type) !== -1) { - return { type, title, payload: value } - } - return { type } - }) - - _.set(msg, 'message.attachment.type', 'template') - _.set(msg, 'message.attachment.payload.template_type', 'generic') - _.set(msg, 'message.attachment.payload.elements', [{ title, item_url, image_url, subtitle, buttons }]) - break - case 'quickReplies': - const text = _.get(message, 'attachment.content.title', '') - const quick_replies = _.get(message, 'attachment.content.buttons', []) - .map(b => ({ content_type: b.type || 'text', title: b.title, payload: b.value })) - - _.set(msg, 'message', { text, quick_replies }) - break - case 'list': { - const elements = _.get(message, 'attachment.content.elements', []) - .map(e => ({ - title: e.title, - image_url: e.imageUrl, - subtitle: e.subtitle, - buttons: e.buttons && e.buttons.map(b => ({ title: b.title, type: b.type, payload: b.value })), - })) - const buttons = _.get(message, 'attachment.content.buttons', []) - .map(b => ({ title: b.title, type: b.type, payload: b.value })) - - _.set(msg, 'message.attachment.type', 'template') - _.set(msg, 'message.attachment.payload', { template_type: 'list', elements }) - - if (buttons.length > 0) { - _.set(msg, 'message.attachment.payload.buttons', buttons) - } - break - } - case 'carousel': - case 'carouselle': - const elements = _.get(message, 'attachment.content', []) - .map(content => { - const { title, itemUrl: item_url, imageUrl: image_url, subtitle } = content - const buttons = _.get(content, 'buttons', []) - .map(({ type, title, value }) => { - if (['web_url', 'account_link'].indexOf(type) !== -1) { - return { type, title, url: value } - } - return { type, title, payload: value } - }) - const element = { title, subtitle, item_url, image_url } - - if (buttons.length > 0) { - _.set(element, 'buttons', buttons) - } - - return element - }) - - _.set(msg, 'message.attachment.type', 'template') - _.set(msg, 'message.attachment.payload.template_type', 'generic') - _.set(msg, 'message.attachment.payload.elements', elements) - break - default: - throw new BadRequestError('Message type non-supported by Messenger') - } - - return msg - } - - static async sendMessage (conversation, message) { - await agent('POST', `https://graph.facebook.com/v2.6/me/messages?access_token=${conversation.channel.token}`) - .send(message) - } - -} - diff --git a/src/services/SlackApp.service.js b/src/services/SlackApp.service.js deleted file mode 100644 index cf32c23..0000000 --- a/src/services/SlackApp.service.js +++ /dev/null @@ -1,136 +0,0 @@ -import _ from 'lodash' -import superagent from 'superagent' -import superagentPromise from 'superagent-promise' - -import ServiceTemplate from './Template.service' -import { StopPipeline, NotFoundError, BadRequestError } from '../utils/errors' - -const agent = superagentPromise(superagent, Promise) - -/* - * checkParamsValidity: ok - * onChannelCreate: ok - * onChannelUpdate: default - * onChannelDelete: ok - * onWebhookChecking: default - * checkSecurity: ok - * beforePipeline: ok - * extractOptions: default - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: default - * formatMessage: ok - * sendMessage: ok - */ - -export default class SlackAppService extends ServiceTemplate { - - static checkParamsValidity (channel) { - if (!channel.clientId) { - throw new BadRequestError('Parameter clientId is missing') - } else if (!channel.clientSecret) { - throw new BadRequestError('Parameter clientSecret is missing') - } - } - - static onChannelCreate (channel) { - channel.oAuthUrl = `${config.base_url}/v1/oauth/slack/${channel._id}` - channel.save() - } - - static async onChannelDelete (channel) { - for (let child of channel.children) { - child = await models.Channel.findById(child) - if (child) { await child.remove() } - } - } - - static checkSecurity (req, res) { - if (req.body && req.body.type === 'url_verification') { - throw new StopPipeline(req.body.challenge) - } - - res.status(200).send() - } - - static async beforePipeline (req, res, channel) { - /* handle action (buttons) to format them */ - if (req.body.payload) { - req.body = SlackAppService.parsePayload(req.body) - } - - /* Search for the App children */ - channel = _.find(channel.children, child => child.slug === req.body.team_id) - if (!channel) { throw new NotFoundError('Channel') } - - /* check if event is only message */ - if (channel.type === 'slack' && req.body && req.body.event && req.body.event.type !== 'message') { - throw new StopPipeline() - } - - /* check if sender is the bot */ - if (req.body.event.user === channel.botuser) { - throw new StopPipeline() - } - - return channel - } - - /* - * SlackApp specifif methods - */ - - static parsePayload (body) { - const parsedBody = JSON.parse(body.payload) - - return ({ - team_id: parsedBody.team.id, - token: parsedBody.token, - event: { - type: 'message', - is_button_click: parsedBody.actions[0].type === 'button', - user: parsedBody.user.id, - text: parsedBody.actions[0].value, - ts: parsedBody.action_ts, - channel: parsedBody.channel.id, - event_ts: parsedBody.action_ts, - }, - type: 'event_callback', - }) - } - - static async receiveOauth (req, res) { - const { channel_id } = req.params - const { code } = req.query - const channel = await models.Channel.findById(channel_id) - - if (!channel) { - throw new NotFoundError('Channel not found') - } - - try { - const { body } = await agent.post(`https://slack.com/api/oauth.access?client_id=${channel.clientId}&client_secret=${channel.clientSecret}&code=${code}`) - if (!body.ok) { throw new Error() } - res.status(200).send() - - const channelChild = await new models.Channel({ - type: 'slack', - app: channel_id, - slug: body.team_id, - connector: channel.connector, - botuser: body.bot.bot_user_id, - token: body.bot.bot_access_token, - }) - channel.children.push(channelChild._id) - - await Promise.all([ - channelChild.save(), - channel.save(), - ]) - } catch (err) { - throw new BadRequestError('[Slack] Failed oAuth subscription') - } - } - -} diff --git a/src/services/Telegram.service.js b/src/services/Telegram.service.js deleted file mode 100644 index a9280d4..0000000 --- a/src/services/Telegram.service.js +++ /dev/null @@ -1,181 +0,0 @@ -import _ from 'lodash' -import superAgent from 'superagent' -import superAgentPromise from 'superagent-promise' - -import { Logger } from '../utils' -import Template from './Template.service' -import { BadRequestError } from '../utils/errors' - -const agent = superAgentPromise(superAgent, Promise) - -/* - * checkParamsValidity: ok - * onChannelCreate: ok - * onChannelUpdate: ok - * onChannelDelete: ok - * onWebhookChecking: default - * checkSecurity: default - * beforePipeline: ok - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - -export default class Telegram extends Template { - - static checkParamsValidity (channel) { - if (!channel.token) { - throw new BadRequestError('token', 'missing') - } - } - - static async onChannelCreate (channel) { - const { token, webhook } = channel - - try { - await Telegram.setWebhook(token, webhook) - channel.isErrored = false - } catch (err) { - Logger.info('[Telegram] Cannot set webhook') - channel.isErrored = true - } - - return channel.save() - } - - static async onChannelUpdate (channel, oldChannel) { - await Telegram.onChannelDelete(oldChannel) - await Telegram.onChannelCreate(channel) - } - - static async onChannelDelete (channel) { - const { token } = channel - - try { - const { status } = await agent.get(`https://api.telegram.org/bot${token}/deleteWebhook`) - - if (status !== 200) { - throw new BadRequestError(`[Telegram][Status ${status}] Cannot delete webhook`) - } - } catch (err) { - Logger.info('[Telegram] Cannot unset the webhook') - channel.isErrored = true - } - } - - static checkSecurity (req, res) { - res.status(200).send({ status: 'success' }) - } - - static extractOptions (req) { - return { - chatId: _.get(req, 'body.message.chat.id'), - senderId: _.get(req, 'body.message.chat.id'), - } - } - - static parseChannelMessage (conversation, { message }, options) { - const type = Object.keys(message).slice(-1)[0] // Get the key name of last message element - const channelType = _.get(conversation, 'channel.type') - const content = _.get(message, `${type}`, '') - - return ([ - conversation, - { - attachment: { type, content }, - channelType, - }, { - ...options, - mentioned: true, - }, - ]) - } - - static formatMessage ({ channel, chatId }, { attachment }, { senderId }) { - const { type, content } = attachment - const reply = { - chatId, - type, - to: senderId, - token: _.get(channel, 'token'), - } - - switch (type) { - case 'text': - case 'video': - return { ...reply, body: content } - case 'picture': - return { ...reply, type: 'photo', body: content } - case 'card': - case 'quickReplies': - return { - ...reply, - type: 'card', - photo: _.get(content, 'imageUrl'), - body: `*${_.get(content, 'title', '')}*\n**${_.get(content, 'subtitle', '')}**`, - keyboard: [_.get(content, 'buttons', []).map(b => ({ text: b.title }))], - } - case 'list': - return { - ...reply, - keyboard: [_.get(content, 'buttons', []).map(b => ({ text: b.title }))], - body: content.elements.map(e => `*- ${e.title}*\n${e.subtitle}\n${e.imageUrl || ''}`), - } - case 'carousel': - case 'carouselle': - return { - ...reply, - body: content.map(({ imageUrl, buttons, title, subtitle }) => ({ - header: `*${title}*\n[${subtitle || ''}](${imageUrl})`, - text: ['```'].concat(buttons.map(({ title, value }) => `${value} - ${title}`)).concat('```').join('\n'), - })), - } - default: - throw new BadRequestError('Message type non-supported by Telegram') - } - } - - static async sendMessage ({ channel }, { token, type, to, body, photo, keyboard }) { - const url = `https://api.telegram.org/bot${token}` - const method = type === 'text' ? 'sendMessage' : `send${_.capitalize(type)}` - - if (type === 'card') { - if (!_.isUndefined(photo)) { - await agent.post(`${url}/sendPhoto`, { chat_id: to, photo }) - } - await agent.post(`${url}/sendMessage`, { chat_id: to, text: body, reply_markup: { keyboard, one_time_keyboard: true }, parse_mode: 'Markdown' }) - } else if (type === 'quickReplies') { - await agent.post(`${url}/sendMessage`, { chat_id: to, text: body, reply_markup: { keyboard, one_time_keyboard: true } }) - } else if (type === 'carousel' || type === 'carouselle') { - body.forEach(async ({ header, text }) => { - await agent.post(`${url}/sendMessage`, { chat_id: to, text: header, parse_mode: 'Markdown' }) - await agent.post(`${url}/sendMessage`, { chat_id: to, text, parse_mode: 'Markdown' }) - }) - } else if (type === 'list') { - for (const elem of body) { - await agent.post(`${url}/sendMessage`, { chat_id: to, text: elem, parse_mode: 'Markdown' }) - } - } else { - await agent.post(`${url}/${method}`, { chat_id: to, [type]: body }) - } - } - - /* - * Telegram specific helpers - */ - - // Set a Telegram webhook - static async setWebhook (token, webhook) { - const url = `https://api.telegram.org/bot${token}/setWebhook` - const { status } = await agent.post(url, { url: webhook }) - - if (status !== 200) { - throw new BadRequestError(`[Telegram][Status ${status}] Cannot set webhook`) - } - } - -} diff --git a/src/services/Template.service.js b/src/services/Template.service.js deleted file mode 100644 index 83747de..0000000 --- a/src/services/Template.service.js +++ /dev/null @@ -1,53 +0,0 @@ -import { noop } from '../utils' - -import { BadRequestError } from '../utils/errors' - -export default class ServiceTemplate { - - /* Check parameters validity to create a Channel */ - static checkParamsValidity = () => true - - /* Call when a channel is created */ - static onChannelCreate = noop - - /* Call when a channel is updated */ - static onChannelUpdate = noop - - /* Call when a channel is deleted */ - static onChannelDelete = noop - - /* Check webhook validity for certain channels (Messenger) */ - static onWebhookChecking = () => { - throw new BadRequestError('Unimplemented service method') - } - - /* Call when a message is received for security purpose */ - static checkSecurity = (req, res) => { - res.status(200).send() - } - - /* Perform operations before entering the pipeline */ - static beforePipeline = (req, res, channel) => channel - - /* Call before entering the pipeline, to build the options object */ - static extractOptions = noop - - /* Call to get the raw message from the received request */ - static getRawMessage = (channel, req) => req.body - - /* Call before entering the pipeline, to send a isTyping message */ - static sendIsTyping = noop - - /* Call to update a conversation based on data from the message */ - static updateConversationWithMessage = (conversation, msg, opts) => { return Promise.all([conversation, msg, opts]) } - - /* Call to parse a message received from a channel */ - static parseChannelMessage = noop - - /* Call to format a message received by the bot */ - static formatMessage = noop - - /* Call to send a message to a bot */ - static sendMessage = noop - -} diff --git a/src/services/Twilio.service.js b/src/services/Twilio.service.js deleted file mode 100644 index 73696a9..0000000 --- a/src/services/Twilio.service.js +++ /dev/null @@ -1,135 +0,0 @@ -import _ from 'lodash' -import crypto from 'crypto' -import superagent from 'superagent' -import superagentPromise from 'superagent-promise' - -import Template from './Template.service' -import { BadRequestError, ForbiddenError } from '../utils/errors' - -const agent = superagentPromise(superagent, Promise) - -/* - * checkParamsValidity: ok - * onChannelCreate: default - * onChannelUpdate: default - * onChannelDelete: default - * onWebhookChecking: default - * checkSecurity: ok - * beforePipeline: default - * extractOptions: ok - * getRawMessage: default - * sendIsTyping: default - * updateConversationWithMessage: default - * parseChannelMessage: ok - * formatMessage: ok - * sendMessage: ok - */ - -export default class Twilio extends Template { - - static checkParamsValidity (channel) { - channel.phoneNumber = channel.phoneNumber.split(' ').join('') - - if (!channel.clientId) { - throw new BadRequestError('Parameter is missing: Client Id') - } else if (!channel.clientSecret) { - throw new BadRequestError('Parameter is missing: Client Secret') - } else if (!channel.serviceId) { - throw new BadRequestError('Parameter is missing: Service Id') - } else if (!channel.phoneNumber) { - throw new BadRequestError('Parameter is missing: Phone Number') - } - } - - static checkSecurity (req, res, channel) { - const signature = req.headers['x-twilio-signature'] - const webhook = channel.webhook - let str = webhook - _.forOwn(_.sortBy(Object.keys(req.body)), (value) => { - str += value - str += req.body[value] - }) - const hmac = crypto.createHmac('SHA1', channel.clientSecret).update(str).digest('base64') - if (signature !== hmac) { - throw new ForbiddenError() - } - } - - static extractOptions (req) { - const { body } = req - - return { - chatId: `${body.To}${body.From}`, - senderId: body.From, - } - } - - static parseChannelMessage (conversation, message, opts) { - const msg = { - attachment: { - type: 'text', - content: message.Body, - }, - } - - return [conversation, msg, { ...opts, mentioned: true }] - } - - static formatMessage (conversation, message, opts) { - const { chatId } = conversation - const { type, content } = message.attachment - const to = opts.senderId - let body = '' - - switch (type) { - case 'text': - case 'picture': - case 'video': { - body = content - break - } - case 'list': { - return _.reduce(content.elements, (acc, elem) => { - return `${acc}\r\n${elem.title}\r\n${elem.subtitle}\r\n${elem.imageUrl}` - }, '') - } - case 'quickReplies': { - const { title, buttons } = content - body = `${title}\r\n`.concat(buttons.map(b => b.title).join('\r\n')) - break - } - case 'card': { - const { title, subtitle, imageUrl, buttons } = content - body = _.reduce(buttons, (acc, b) => `${acc}\r\n- ${b.title}`, `${title}\r\n${subtitle}\r\n${imageUrl}`) - break - } - case 'carouselle': - case 'carousel': { - body = _.reduce(content, (acc, card) => { - const { title, subtitle, imageUrl, buttons } = card - return acc + _.reduce(buttons, (acc, b) => `${acc}\n- ${b.title}`, `${title}\n${subtitle}\n${imageUrl}\n`) - }, '') - break - } - default: - throw new BadRequestError('Message type non-supported by Twilio') - } - - return { chatId, to, body, type } - } - - static async sendMessage (conversation, message) { - const data = { - To: message.to, - Body: message.body, - From: conversation.channel.phoneNumber, - MessagingServiceSid: conversation.channel.serviceId, - } - - await agent('POST', `https://api.twilio.com/2010-04-01/Accounts/${conversation.channel.clientId}/Messages.json`) - .auth(conversation.channel.clientId, conversation.channel.clientSecret) - .type('form') - .send(data) - } - -} diff --git a/src/services/index.js b/src/services/index.js deleted file mode 100644 index eb9acf4..0000000 --- a/src/services/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import Kik from './Kik.service' -import Slack from './Slack.service' -import SlackApp from './SlackApp.service' -import Messenger from './Messenger.service' -import Callr from './Callr.service' -import Twilio from './Twilio.service' -import Telegram from './Telegram.service' -import CiscoSpark from './CiscoSpark.service' -import Twitter from './Twitter.service' -import Microsoft from './Microsoft.service' - -export default { - Callr, - CiscoSpark, - Kik, - Messenger, - Microsoft, - Telegram, - Twilio, - Twitter, - Slack, - SlackApp, -} diff --git a/src/test.js b/src/test.js deleted file mode 100644 index f3dfd83..0000000 --- a/src/test.js +++ /dev/null @@ -1,109 +0,0 @@ -import cors from 'cors' -import express from 'express' -import mongoose from 'mongoose' -import bodyParser from 'body-parser' - -/* Test Framework */ -import Mocha from 'mocha' - -import istanbul from 'istanbul' - -import configs from '../config' -import { createRouter } from './routes/' -import { initServices } from './utils/init' - -import path from 'path' -import recursive from 'recursive-readdir' - -import Logger from './utils/Logger' - -/* eslint-disable max-nested-callbacks*/ -global.Bot = require('./models/Bot.model') -global.Channel = require('./models/Channel.model') -global.Conversation = require('./models/Conversation.model') -global.Message = require('./models/Message.model') -global.Participant = require('./models/Participant.model') - -const app = express() - -// Load the configuration -const env = process.env.NODE_ENV || 'test' - -const config = configs[env] - -// Enable Cross Origin Resource Sharing -app.use(cors()) - -// Enable auto parsing of json content -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: true })) - -// Use native promise API with mongoose -mongoose.Promise = global.Promise - -// Mongoose connection -/* eslint-disable no-console */ -/* eslint-disable max-nested-callbacks */ -mongoose.connect(`mongodb://${config.db.host}:${config.db.port}/${config.db.dbName}`) -const db = mongoose.connection -db.on('error', err => { - Logger.error('FAILED TO CONNECT', err) - process.exit(1) -}) - -// Launch the application -db.once('open', () => { - createRouter(app) - initServices() - global.app = app - const port = config.server.port - app.listen(port, () => { - app.emit('ready') - Logger.success(`Test app listening on port ${port} !`) - Logger.info('[TEST] Launching test runner...') - - const mocha = new Mocha({ - reporter: 'dot', - timeout: '2000', - }) - const collector = new istanbul.Collector() - const reporter = new istanbul.Reporter() - const testsDirectory = './test' - - recursive(testsDirectory, (err, files) => { - if (err) { - process.exit(2) - } - // Files is an array of filename - Logger.info('[TEST] Listing test files...') - files.filter(file => { - return file.substr(-9) === '.tests.js' - }).forEach(file => { - mocha.addFile( - path.join('./', file) - ) - }) - - process.env.ROUTETEST = `http://localhost:${config.server.port}` - - mocha.run(errCount => { - mongoose.connection.db.dropDatabase() - - collector.add(global.__coverage__) - reporter.addAll(['text-summary', 'html']) - reporter.write(collector, true, () => { - Logger.info('\nCoverage report saved to coverage/index.html') - }) - - if (errCount > 0) { - Logger.info(`Total error${(errCount > 1 ? 's' : '')}: ${errCount}`) - process.exit(1) - } - process.exit(0) - }) - }) - }) -}) -/* eslint-enable max-nested-callbacks */ -/* eslint-enable no-console */ -/* eslint-enable max-nested-callbacks*/ diff --git a/src/utils/Logger.js b/src/utils/Logger.js deleted file mode 100644 index f2ba415..0000000 --- a/src/utils/Logger.js +++ /dev/null @@ -1,48 +0,0 @@ -const COLOR = { - BLACK: '30', - RED: '31', - GREEN: '32', - YELLOW: '33', - BLUE: '34', - PINK: '35', - CYAN: '36', - GREY: '37', -} - -/* eslint-disable no-console */ -class Logger { - static error (...messages) { - if (process.env.NODE_ENV === 'test') { return } - - messages.map(message => Logger.show(message, COLOR.RED)) - } - - static success (...messages) { - messages.map(message => Logger.show(message, COLOR.GREEN)) - } - - static warning (...messages) { - messages.map(message => Logger.show(message, COLOR.YELLOW)) - } - - static info (...messages) { - messages.map(message => Logger.show(message, COLOR.CYAN)) - } - - static log (...messages) { - messages.map(message => Logger.show(message)) - } - - static show (message, color) { - if (process.env.NODE_ENV !== 'test') { - if (!color) { - console.log(message) - } else { - console.log(`\x1b[${color}m`, `${message}`, '\x1b[0m') - } - } - } -} - -module.exports = Logger -/* eslint-esable no-console */ diff --git a/src/utils/errors.js b/src/utils/errors.js index 7352aad..4b44c73 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -1,103 +1,100 @@ -import { - Logger, +import { logger } from '../utils' - renderBadRequest, - renderForbidden, - renderUnauthorized, - renderNotFound, - renderConflict, - renderInternalServerError, - renderStopPipeline, - renderServiceUnavailable, -} from '../utils' +export class AppError extends Error { + constructor (message = '', results = null, status = 500) { + super(message) + this.constructor = AppError + // eslint-disable-next-line no-proto + this.__proto__ = AppError.prototype + this.name = this.constructor.name + Error.captureStackTrace(this, this.constructor) + this.status = status + this.content = { message, results } + } + render (res) { + return res.status(this.status).json(this.content) + } +} /** * 400 - Bad request */ -export class BadRequestError { - constructor (message = null, results = null) { - this.content = { message, results } +export class BadRequestError extends AppError { + constructor (message, results) { + super(message, results, 400) } } /** * 401 - Forbidden */ -export class ForbiddenError { - constructor (message = 'Request can not be processed with your role', results = null) { - this.content = { message, results } +export class ForbiddenError extends AppError { + constructor (message = 'Request can not be processed with your role', results) { + super(message, results, 401) } } /** * 403 - Unauthorized */ -export class UnauthorizedError { - constructor (message = 'Request can not be processed without authentication', results = null) { - this.content = { message, results } +export class UnauthorizedError extends AppError { + constructor (message = 'Request can not be processed without authentication', results) { + super(message, results, 403) } } /** * 404 - Not found */ -export class NotFoundError { - constructor (target = 'Model', results = null) { - this.content = { results, message: `${target} not found` } +export class NotFoundError extends AppError { + constructor (target = 'Model', results) { + const message = `${target} not found` + super(message, results, 404) } } -/** +/* * 409 - Conflict */ -export class ConflictError { - constructor (message, results = null) { - this.content = { results, message } +export class ConflictError extends AppError { + constructor (message, results) { + super(message, results, 409) } } -/** +/* * 503 - Service unavailable */ -export class ServiceError { - constructor (message, results = null) { - this.content = { message, results } +export class ServiceError extends AppError { + constructor (message, results) { + super(message, results, 503) } } /** - * Used to stop the pipeline + * 200 - Stop Pipeline */ export class StopPipeline { constructor (content) { + logger.warning('Abuse of JS exception mechanism') this.content = content } + render (res) { + return res.status(200).send(this.content) + } } /** * Render the appropriate error */ -export const renderConnectorError = (res, err) => { - if (res.headersSent) { return } - - if (err instanceof StopPipeline) { - return renderStopPipeline(res, err.content) - } - - if (err instanceof NotFoundError) { - return renderNotFound(res, err.content) - } else if (err instanceof BadRequestError) { - return renderBadRequest(res, err.content) - } else if (err instanceof ForbiddenError) { - return renderForbidden(res, err.content) - } else if (err instanceof UnauthorizedError) { - return renderUnauthorized(res, err.content) - } else if (err instanceof ConflictError) { - return renderConflict(res, err.content) - } else if (err instanceof ServiceError) { - return renderServiceUnavailable(res, err.content) +export const renderError = (res, err) => { + if (err instanceof AppError || err instanceof StopPipeline) { + return err.render(res) } + logger.error('Internal Server Error', (err && err.stack) || (err && err.message) || err) - Logger.error('Internal server error', (err && err.stack) || (err && err.message) || err) - return renderInternalServerError(res, err) + // Prevent sending twice a response in case of + // forwarding message from a channel to a bot + if (res.headersSent) { return } + return res.status(500).json(err) } diff --git a/src/utils/format.js b/src/utils/format.js index 86708f1..db85d31 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -1,19 +1,195 @@ import _ from 'lodash' +import { Validator } from 'jsonschema' -export const messageTypes = ['text', 'picture', 'video', 'quickReplies', 'card', 'carouselle', 'audio'] +import { BadRequestError } from './' + +const basicSchema = { + id: '/Basic', + type: 'object', + properties: { + type: { type: 'string' }, + content: { type: 'string' }, + }, + required: ['type', 'content'], +} + +const buttonSchema = { + id: '/Button', + type: 'object', + properties: { + type: { type: 'string' }, + title: { type: 'string' }, + value: { type: 'string' }, + }, +} + +const buttonsSchema = { + id: '/Buttons', + type: 'object', + properties: { + content: { + type: 'object', + properties: { + title: { type: 'string' }, + buttons: { + type: 'array', + items: { $ref: '/Button' }, + }, + }, + required: ['buttons'], + }, + }, + required: ['content'], +} + +const quickRepliesSchema = { + id: '/QuickReplies', + type: 'object', + properties: { + type: { type: 'string' }, + content: { + type: 'object', + properties: { + title: { type: 'string' }, + buttons: { + type: 'array', + items: { $ref: '/Button' }, + }, + }, + required: ['title', 'buttons'], + }, + }, + required: ['type', 'content'], +} + +const customSchema = { + id: '/Custom', + type: 'object', + properties: { + content: { + anyOf: [ + { + type: 'object', + patternProperties: { + '^([A-Za-z0-9-_]+)$': {}, + }, + additionalProperties: false, + }, + { + type: 'array', + }, + { + type: 'string', + }, + ], + }, + }, + required: ['content'], +} + +const val = new Validator() +val.addSchema(basicSchema, '/Basic') +val.addSchema(buttonSchema, '/Button') +val.addSchema(buttonsSchema, '/Buttons') +val.addSchema(quickRepliesSchema, '/QuickReplies') +val.addSchema(customSchema, '/Custom') + +export const messageTypes = [ + 'text', + 'conversation_start', + 'conversation_end', + 'picture', + 'video', + 'quickReplies', + 'card', + 'carouselle', + 'audio', + 'carousel', + 'list', + 'buttons', + 'custom', +] + +export const validate = (message, schema) => { + const { errors: [error] } = val.validate(message, schema) + + return error +} export function isValidFormatMessage (message) { if (!_.isObject(message) - || !message.type || !message.content - || messageTypes.indexOf(message.type) === -1) { + || !message.type || !message.content + || messageTypes.indexOf(message.type) === -1) { return false } - if (message.type === 'text' && !_.isString(message.content)) { return false } - if (message.type === 'picture' && !_.isString(message.content)) { return false } - if (message.type === 'video' && !_.isString(message.content)) { return false } - if (message.type === 'quickReplies' && !_.isObject(message.content)) { return false } - if (message.type === 'card' && !_.isObject(message.content)) { return false } + const { type, content } = message + if (type === 'text' && !_.isString(content)) { return false } + if (type === 'conversation_start' && !_.isString(content)) { return false } + if (type === 'conversation_end' && !_.isString(content)) { return false } + if (type === 'picture' && !_.isString(content)) { return false } + if (type === 'video' && !_.isString(content)) { return false } + if (type === 'quickReplies' && !_.isObject(content)) { return false } + if (type === 'card' && !_.isObject(content)) { return false } + if (type === 'buttons' && !_.isObject(content)) { return false } + if (type === 'custom' + && !_.isObject(content) + && !_.isArray(content) + ) { + return false + } return true } + +export const textFormatMessage = (message, separator = '\n', buttonSeparator = '- ') => { + const { attachment: { type, content } } = message + + let body = '' + switch (type) { + case 'text': + case 'picture': + case 'video': { + body = content + break + } + case 'list': { + const { elements } = content + body = _.reduce(elements, (acc, elem) => `${acc}${separator}` + + `${separator}${elem.title}` + + `${separator}${elem.subtitle}` + + `${separator}${elem.imageUrl}`, '') + break + } + case 'buttons': + case 'quickReplies': { + const { title, buttons } = content + body = `${title}${separator}` + .concat(buttons.map(b => `${buttonSeparator}${b.title}`) + .join(separator)) + break + } + case 'card': { + const { title, subtitle, imageUrl, buttons } = content + body = _.reduce(buttons, (acc, b) => + `${acc}${separator}${buttonSeparator}${b.title}`, + `${title}${separator}${subtitle}${separator}${imageUrl}`, '') + break + } + case 'carouselle': + case 'carousel': { + body = _.reduce(content, (acc, card) => { + const { title, subtitle, imageUrl, buttons } = card + // eslint-disable-next-line prefer-template + return acc + _.reduce(buttons, (acc, b) => + `${acc}${buttonSeparator}${b.title}${separator}`, + `${title}${separator}${subtitle}${separator}${imageUrl}${separator}`, '') + separator + }, '') + break + } + default: + throw new BadRequestError('Message type non-supported by text based service') + } + + return { type, body } +} diff --git a/src/utils/headers.js b/src/utils/headers.js new file mode 100644 index 0000000..2dade0a --- /dev/null +++ b/src/utils/headers.js @@ -0,0 +1,13 @@ +import _ from 'lodash' + +const AUTH_HEADERS = [ + 'authorization', + 'x-recast-user', +] + +const getAuthHeaders = (headers) => _.pickBy(headers, (_value, key) => AUTH_HEADERS.includes(key)) + +module.exports = { + AUTH_HEADERS, + getAuthHeaders, +} diff --git a/src/utils/index.js b/src/utils/index.js index 6ec8b67..e3f831d 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,18 +1,33 @@ export { noop, - invoke, - invokeSync, + arrayfy, isInvalidUrl, getWebhookToken, + getTwitterWebhookToken, + deleteTwitterWebhook, + postMediaToTwitterFromUrl, + sendToWatchers, + removeOldRedisMessages, + findUserRealName, + messageHistory, + formatMessageHistory, + formatUserMessage, } from './utils' export { + lineSendMessage, + lineGetUserProfile, +} from './line' + +export { + AppError, BadRequestError, ForbiddenError, UnauthorizedError, NotFoundError, ServiceError, - renderConnectorError, + renderError, + StopPipeline, } from './errors' export { @@ -27,7 +42,9 @@ export { renderInternalServerError, renderServiceUnavailable, renderStopPipeline, + renderPolledMessages, } from './responses' -export Logger from './Logger' -export { messageTypes, isValidFormatMessage } from './format' +export { logger } from './log' +export { fmtConversationHeader, fmtMessageDate, sendMail, sendArchiveByMail } from './mail' +export { messageTypes, isValidFormatMessage, textFormatMessage } from './format' diff --git a/src/utils/line.js b/src/utils/line.js new file mode 100644 index 0000000..f56a9c0 --- /dev/null +++ b/src/utils/line.js @@ -0,0 +1,31 @@ +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import { ServiceError } from './' + +const agent = superagentPromise(superagent, Promise) + +const getLineApiUrl = url => `https://api.line.me/${url}` + +export const lineSendMessage = async (clientToken, replyToken, messages) => { + // Line only supports up to 5 messages in one request, but the replyToken can + // only be used once. Therefore, we only send the first 5 messages for now. + await agent.post(getLineApiUrl('v2/bot/message/reply')) + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${clientToken}`) + .send({ + replyToken, + messages: messages.slice(0, 5), + }) +} + +export const lineGetUserProfile = async (clientToken, userId) => { + try { + const { body } = await agent.get(getLineApiUrl(`v2/bot/profile/${userId}`)) + .set('Authorization', `Bearer ${clientToken}`) + + return body + } catch (error) { + throw new ServiceError('Error while retrieving line user profile information') + } +} diff --git a/src/utils/log.js b/src/utils/log.js new file mode 100644 index 0000000..a391cac --- /dev/null +++ b/src/utils/log.js @@ -0,0 +1,28 @@ +import winston from 'winston' + +export const winstonLogger = new winston.Logger({ + transports: [ + new winston.transports.Console({ + colorize: true, + handleExceptions: true, + level: 'info', + prettyPrint: true, + timestamp: true, + }), + ], + exitOnError: false, +}) + +winstonLogger.stream = { + write: (message) => { + winstonLogger.info(message) + }, +} + +export const logger = { + error: (...messages) => messages.map(message => winstonLogger.error(message)), + warning: (...messages) => messages.map(message => winstonLogger.warn(message)), + info: (...messages) => messages.map(message => winstonLogger.info(message)), + debug: (...messages) => messages.map(message => winstonLogger.debug(message)), +} + diff --git a/src/utils/mail.js b/src/utils/mail.js new file mode 100644 index 0000000..eee4bc1 --- /dev/null +++ b/src/utils/mail.js @@ -0,0 +1,51 @@ +import archiver from 'archiver' +import nodemailer from 'nodemailer' + +import { logger } from './index' +import config from '../../config' + +export const fmtMessageDate = (message) => { + const date = message.receivedAt || message.createdAt || new Date() + return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}` +} + +export const fmtConversationHeader = (conversation, participants) => { + const user = participants.find(p => !p.isBot) + const userInformations = user && user.data + ? 'Participant informations:\n' + + `- SenderId: ${user.senderId}\n` + + `${Object.keys(user.data).map(k => `- ${k} => ${user.data[k]}`).join('\n')}\n` + : `Participant informations:\n- SenderId: ${user && user.senderId}\n` + const conversationInformations = `Conversation informations: +- Chat ID: ${conversation._id} +- created: ${conversation.createdAt} + ` + + return `${conversationInformations}\n${userInformations}\n` +} + +export async function sendArchiveByMail (message) { + const archive = archiver('zip', { + zlib: { level: 9 }, + }) + + archive.on('error', err => { + logger.error(`Failed to create the zip archive: ${err}`) + throw err + }) + + for (const file of message.attachments) { + archive.append(file.content, { name: file.filename }) + } + archive.finalize() + + return sendMail({ + ...message, + attachments: [{ content: archive, filename: 'conversations.zip' }], + }) +} + +export function sendMail (message) { + return nodemailer.createTransport(config.mail) + .sendMail(message) +} diff --git a/src/utils/message_queue.js b/src/utils/message_queue.js new file mode 100644 index 0000000..b9df8cd --- /dev/null +++ b/src/utils/message_queue.js @@ -0,0 +1,54 @@ +import config from '../../config' +import kue from 'kue' +import { logger } from './index' + +export class MessageQueue { + constructor () { + this.pollWatchers = {} + // Redis queue to handle messages to send to long-polling sessions + this.queue = kue.createQueue({ redis: config.redis }) + } + + subscribeToEvents () { + const self = this + this.queue.on('error', (err) => { + logger.error(`Unexpected Redis/Kue error: ${err}`) + process.exit(1) + }) + // message received in a conversation + this.queue.on('job enqueue', (id, conversationId) => { + const watchers = self.pollWatchers[conversationId] + if (!watchers || Object.keys(watchers).length <= 0) { return } + kue.Job.get(id, (err, message) => { + if (err) { return logger.error(`Error while getting message from Redis: ${err}`) } + Object.values(watchers).forEach(watcher => watcher.handler(message.data)) + }) + }) + } + + removeWatcher (conversationId, watcherId) { + if (this.pollWatchers[conversationId]) { + delete this.pollWatchers[conversationId][watcherId] + if (Object.keys(this.pollWatchers[conversationId]).length <= 0) { + delete this.pollWatchers[conversationId] + } + } + } + + setWatcher (conversationId, watcherId, watcher) { + if (!this.pollWatchers[conversationId]) { + this.pollWatchers[conversationId] = {} + } + this.pollWatchers[conversationId][watcherId] = { handler: watcher } + } + + getQueue () { + return this.queue + } + +} + +const defaultQueue = new MessageQueue() +defaultQueue.subscribeToEvents() + +export default defaultQueue diff --git a/src/utils/responses.js b/src/utils/responses.js index 61760e3..af3ce8f 100644 --- a/src/utils/responses.js +++ b/src/utils/responses.js @@ -13,34 +13,12 @@ export const renderDeleted = (res, message) => { }) } -export const renderBadRequest = (res, content) => { - return res.status(400).json(content) -} - -export const renderForbidden = (res, content) => { - return res.status(401).json(content) -} - -export const renderUnauthorized = (res, content) => { - return res.status(403).json(content) -} - -export const renderNotFound = (res, content) => { - return res.status(404).json(content) -} - -export const renderConflict = (res, content) => { - return res.status(409).json(content) -} - -export const renderInternalServerError = (res, content) => { - return res.status(500).json(content) -} - -export const renderServiceUnavailable = (res, content) => { - return res.status(503).json(content) -} - -export const renderStopPipeline = (res, content) => { - return res.status(200).send(content) +export const renderPolledMessages = (res, messages, waitTime) => { + return res.status(200).json({ + message: `${messages.length} messages`, + results: { + messages, + waitTime, + }, + }) } diff --git a/src/utils/utils.js b/src/utils/utils.js index d23dfc4..94f8664 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -8,6 +8,11 @@ import http from 'http' import fs from 'fs' import request2 from 'superagent' import tmp from 'tmp' +import kue from 'kue' +import _ from 'lodash' +import { Message, Participant } from '../models' +import { logger } from './index' +import messageQueue from './message_queue' export function getWebhookToken (id, slug) { return md5(id.toString().split('').reverse().join(''), slug) @@ -19,20 +24,17 @@ export function getTwitterWebhookToken (first, second) { return 'sha256='.concat(hmac.digest('base64')) } -export function deleteTwitterWebhook (T, webhookToken) { +export function deleteTwitterWebhook (T, webhookToken, envName) { + T.config.app_only_auth = false return new Promise((resolve, reject) => { - T._buildReqOpts('DELETE', 'account_activity/webhooks/'.concat(webhookToken), {}, false, (err, reqOpts) => { - if (err) { - reject(err) - return - } - T._doRestApiRequest(reqOpts, {}, 'DELETE', (err) => { - if (err) { - return reject(err) - } - resolve() + T._buildReqOpts('DELETE', + `account_activity/all/${envName}/webhooks/${webhookToken}`, {}, false, (err, reqOpts) => { + if (err) { return reject(err) } + T._doRestApiRequest(reqOpts, {}, 'DELETE', (err) => { + if (err) { return reject(err) } + return resolve() + }) }) - }) }) } @@ -104,22 +106,93 @@ export function noop () { } /** - * Invoke an async service method + * Check if an url is valid */ -export async function invoke (serviceName, methodName, args) { - return global.services[serviceName][methodName](...args) +export const isInvalidUrl = url => (!url || (!is.url(url) && !(/localhost/).test(url))) + +export const arrayfy = (content) => [].concat.apply([], [content]) + +export function sendToWatchers (convId, msgs) { + return new Promise((resolve, reject) => { + messageQueue.getQueue().create(convId, msgs) + .save(err => { + if (err) { return reject(err) } + resolve() + }) + }) } -/** - * Invoke a sync service method - */ -export function invokeSync (serviceName, methodName, args) { - return global.services[serviceName][methodName](...args) +export function removeOldRedisMessages () { + const now = Date.now() + // -1 means that we don't limit the number of results, we could set that to 1000 + kue.Job.rangeByState('inactive', 0, -1, 'asc', (err, messages) => { + if (err) { return logger.error(`Error while getting messages from Redis: ${err}`) } + messages.forEach(msg => { + if (now - msg.created_at > 10 * 1000) { msg.remove() } + }) + }) } -/** - * Check if an url is valid - */ -export const isInvalidUrl = url => (!url || (!is.url(url) && !(/localhost/).test(url))) +export async function findUserRealName (conversation) { + const participants = await Participant.find({ conversation: conversation._id }) + const users = participants.filter(p => !p.isBot && !p.type === 'agent') + if (users.length === 0) { + return `Anonymous user from ${conversation.channel.type}` + } + const user = users[0] -export const arrayfy = (content) => [].concat.apply([], [content]) + if (user && user.data && user.data.userName) { + return user.userName + } + if (user && user.data && user.data.first_name && user.data.last_name) { + return `${user.data.first_name} ${user.data.last_name}` + } + return `Anonymous user from ${conversation.channel.type}` +} + +export async function messageHistory (conversation) { + const lastMessages = await Message + .find({ conversation: conversation._id }) + .sort({ receivedAt: -1 }) + .populate('participant') + .exec() + + return lastMessages +} + +export function formatMessageHistory (history) { + return history + .map(m => { + const message = formatUserMessage(m) + switch (m.participant.type) { + case 'bot': + return `Bot: ${message}` + case 'agent': + return `Agent: ${message}` + default: + return `User: ${message}` + } + }) + .concat('This is the history of the conversation between the user and the bot:') + .reverse() +} + +export function formatUserMessage (message) { + switch (message.attachment.type) { + case 'text': + return message.attachment.content + case 'picture': + return '[Image]' + default: + return '[Rich message]' + } +} + +_.mixin({ + sortByKeys: (obj, comparator) => + _(obj).toPairs() + .sortBy( + pair => comparator ? comparator(pair[1], pair[0]) : 0 + ) + .fromPairs(), +}) diff --git a/src/validators/Channels.validators.js b/src/validators/Channels.validators.js deleted file mode 100644 index 7fe0383..0000000 --- a/src/validators/Channels.validators.js +++ /dev/null @@ -1,21 +0,0 @@ -import filter from 'filter-object' - -import { invoke } from '../utils' -import { BadRequestError } from '../utils/errors' - -const permitted = '{type,slug,isActivated,token,userName,apiKey,webhook,clientId,clientSecret,password,phoneNumber,serviceId}' - -export async function create (req) { - const { slug, type } = req.body - const newChannel = new models.Channel(filter(req.body, permitted)) - - if (!type) { - throw new BadRequestError('Parameter type is missing') - } else if (!slug) { - throw new BadRequestError('Parameter slug is missing') - } else if (!services[type]) { - throw new BadRequestError('Parameter type is invalid') - } - - await invoke(newChannel.type, 'checkParamsValidity', [newChannel]) -} diff --git a/src/validators/channels.js b/src/validators/channels.js new file mode 100644 index 0000000..4ba929b --- /dev/null +++ b/src/validators/channels.js @@ -0,0 +1,46 @@ +import filter from 'filter-object' + +import * as channelConstants from '../constants/channels' +import { BadRequestError } from '../utils/errors' +import { Channel } from '../models' +import { getChannelIntegrationByIdentifier } from '../channel_integrations' +import validatorConfig from './validators-config' + +const throwErrIfStringExceedsMax = (str, max, errDisplayName) => { + if (str && (str.length > max)) { + throw new BadRequestError(`${errDisplayName} should be at most ${max} characters long`) + } +} + +const throwErrIfPlaceholderExceedsMax = (placeholderText) => { + return () => throwErrIfStringExceedsMax( + placeholderText, validatorConfig.USER_INPUT_PLACEHOLDER_MAX, 'User input placeholder') +} + +export async function createChannelByConnectorId (req) { + const slug = req.body.slug + const type = req.body.type + const inputPlaceholder = req.body.userInputPlaceholder + const newChannel = new Channel(filter(req.body, channelConstants.permitted)) + + if (!type) { + throw new BadRequestError('Parameter type is missing') + } else if (!slug) { + throw new BadRequestError('Parameter slug is missing') + } else if (!getChannelIntegrationByIdentifier(type)) { + throw new BadRequestError('Parameter type is invalid') + } + throwErrIfPlaceholderExceedsMax(inputPlaceholder)() + + const channelIntegration = getChannelIntegrationByIdentifier(newChannel.type) + channelIntegration.validateChannelObject(newChannel) +} + +export const updateChannel = (req) => { + const slug = req.body.slug + + if (slug === '') { + throw new BadRequestError('Parameter slug cannot be empty') + } + throwErrIfPlaceholderExceedsMax(req.body.userInputPlaceholder)() +} diff --git a/src/validators/Connectors.validators.js b/src/validators/connectors.js similarity index 79% rename from src/validators/Connectors.validators.js rename to src/validators/connectors.js index eccc1b0..6cc09e4 100644 --- a/src/validators/Connectors.validators.js +++ b/src/validators/connectors.js @@ -2,7 +2,7 @@ import { isInvalidUrl } from '../utils' import { BadRequestError } from '../utils/errors' export const createConnector = (req) => { - const { url } = req.body + const url = req.body.url if (!url) { throw new BadRequestError('Parameter url is missing') @@ -11,8 +11,8 @@ export const createConnector = (req) => { } } -export const updateConnectorByBotId = (req) => { - const { url } = req.body +export const updateConnector = (req) => { + const url = req.body.url if (url && isInvalidUrl(url)) { throw new BadRequestError('Parameter url is invalid') diff --git a/src/validators/index.js b/src/validators/index.js new file mode 100644 index 0000000..2d0b124 --- /dev/null +++ b/src/validators/index.js @@ -0,0 +1,2 @@ +export ConnectorsValidators from './connectors' +export ChannelsValidators from './channels' diff --git a/src/validators/validators-config.js b/src/validators/validators-config.js new file mode 100644 index 0000000..bcdf9f1 --- /dev/null +++ b/src/validators/validators-config.js @@ -0,0 +1,5 @@ +const validatorConfig = { + USER_INPUT_PLACEHOLDER_MAX: 35, +} + +export default Object.freeze(validatorConfig) diff --git a/test/channel_integrations/modules.js b/test/channel_integrations/modules.js new file mode 100644 index 0000000..d031be5 --- /dev/null +++ b/test/channel_integrations/modules.js @@ -0,0 +1,93 @@ +import chai from 'chai' +import * as integrations_module from '../../src/channel_integrations' +const { getChannelIntegrationByIdentifier, getChannelIntegrationRoutes, + MODULES } = integrations_module +import Callr from '../../src/channel_integrations/callr/channel' + +const should = chai.should() +const expect = chai.expect + +/* eslint no-unused-expressions: 0 */ // --> OFF + +function validateRouteObject (route) { + expect(['GET', 'POST', 'PUT']).to.include(route.method) + expect(route.path).to.be.an('array').that.is.not.empty + expect(route.validators).to.be.an('array') + expect(route.authenticators).to.be.an('array') + expect(route.handler).to.be.a('function') +} + +const sourcePath = '../../src/channel_integrations' +// flatten +const IDENTIFIERS = [].concat.apply([], MODULES.map(moduleName => require(`${sourcePath}/${moduleName}`).identifiers)) + +MODULES.forEach(moduleName => describe(`${moduleName} channel integration module`, () => { + + let module + beforeEach(() => { + module = require(`${sourcePath}/${moduleName}`) + }) + + it('should have unique identifiers', () => { + module.identifiers.forEach(identifier => { + const count = IDENTIFIERS.filter(i => i === identifier).length + expect(count).to.equal(1) + }) + }) + + it('should export all mandatory fields', () => { + should.exist(module) + should.exist(module.channel) + should.exist(module.identifiers) + expect(module.identifiers).to.be.an('array') + module.identifiers.forEach(i => expect(i).to.be.a('string')) + }) + + it('should export optional fields with the correct type', () => { + if (module.routes) { expect(module.routes).to.be.an('array') } + }) + + it('should define valid routes', () => { + if (!module.routes) { return } + module.routes.forEach(validateRouteObject) + }) + + it('should define all mandatory service methods') + +})) + +describe('getChannelIntegrationByIdentifier', () => { + + it('should return undefined for unknown identifiers', () => { + expect(getChannelIntegrationByIdentifier('unknown')).to.be.undefined + }) + + describe('with correct identifier', () => { + + let integration + + beforeEach(() => { + integration = getChannelIntegrationByIdentifier('callr') + }) + + it('should return a channel integration instance', () => { + expect(integration).to.be.an.instanceof(Callr) + }) + + it('should return an instance which overrides abstract methods', () => { + const body = { body: { data: { from: 'me', to: 'you' } } } + expect(integration.populateMessageContext(body)).to.eql({ chatId: 'me', senderId: 'you' }) + }) + }) +}) + +describe('getChannelIntegrationRoutes', () => { + + it('should return a non-empty array', () => { + expect(getChannelIntegrationRoutes()).to.be.an('array').that.is.not.empty + }) + + it('should only contain proper route objects', () => { + getChannelIntegrationRoutes().forEach(validateRouteObject) + }) +}) diff --git a/test/controllers/Bots.controller.tests.js b/test/controllers/Bots.controller.tests.js deleted file mode 100644 index 671f539..0000000 --- a/test/controllers/Bots.controller.tests.js +++ /dev/null @@ -1,155 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' -import Bot from '../../src/models/Bot.model' -import Conversation from '../../src/models/Conversation.model' -import Channel from '../../src/models/Channel.model' - -import BotsController from '../../src/controllers/Bots.controller' - -import sinon from 'sinon' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -function clearDB () { - for (const i in mongoose.connection.collections) { - if (mongoose.connection.collections.hasOwnProperty(i)) { - mongoose.connection.collections[i].remove() - } - } -} - -const url = 'https://bonjour.com' -const baseUrl = 'http://localhost:8080' -const updatedUrl = 'https://aurevoir.com' - -describe('Bot controller', () => { - describe('POST: create a bot', () => { - after(async () => clearDB()) - it ('should be a 201', async () => { - const res = await chai.request(baseUrl) - .post('/bots').send({ url }) - const { message, results } = res.body - - assert.equal(res.status, 201) - assert.equal(results.url, url) - assert.equal(message, 'Bot successfully created') - }) - }) - - describe('GET: get bots', async () => { - after(async () => clearDB()) - afterEach(async () => clearDB()) - - it ('should be a 200 with bots', async () => { - await Promise.all([ - new Bot({ url }).save(), - new Bot({ url }).save(), - ]) - const res = await chai.request(baseUrl).get('/bots').send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.length, 2) - assert.equal(message, 'Bots successfully found') - }) - - it ('should be a 200 with no bots', async () => { - const res = await chai.request(baseUrl).get('/bots').send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.length, 0) - assert.equal(message, 'No Bots') - }) - }) - - describe('GET: get bot by id', () => { - after(async () => clearDB()) - - it ('should be a 200 with bots', async () => { - const bot = await new Bot({ url }).save() - const res = await chai.request(baseUrl).get(`/bots/${bot._id}`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.id, bot._id) - assert.equal(results.url, bot.url) - assert.equal(message, 'Bot successfully found') - }) - - it ('should be a 404 with no bots', async () => { - try { - await chai.request(baseUrl).get('/bots/582a4ced73b15653c074606b').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Bot not found') - } - }) - }) - - describe('PUT: update a bot', () => { - let bot = {} - before(async () => bot = await new Bot({ url }).save()) - after(async () => clearDB()) - - it ('should be a 200', async () => { - const res = await chai.request(baseUrl).put(`/bots/${bot._id}`) - .send({ url: updatedUrl }) - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.url, updatedUrl) - assert.equal(message, 'Bot successfully updated') - }) - - it ('should be a 404 with no bots', async () => { - try { - await chai.request(baseUrl).put('/bots/582a4ced73b15653c074606b').send({ url }) - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Bot not found') - } - }) - }) - - describe('DELETE: delete a bot', () => { - it ('should be a 200', async () => { - let bot = await new Bot({ url }).save() - const res = await chai.request(baseUrl).del(`/bots/${bot._id}`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results, null) - assert.equal(message, 'Bot successfully deleted') - }) - - it ('should be a 404 with no bots', async () => { - try { - await chai.request(baseUrl).del('/bots/582a4ced73b15653c074606b').send({ url }) - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Bot not found') - } - }) - }) -}) diff --git a/test/controllers/Channels.controller.tests.js b/test/controllers/Channels.controller.tests.js deleted file mode 100644 index 3412a3b..0000000 --- a/test/controllers/Channels.controller.tests.js +++ /dev/null @@ -1,196 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import sinon from 'sinon' - -import model from '../../src/models' -import Bot from '../../src/models/Bot.model' -import Channel from '../../src/models/Channel.model' -import KikService from '../../src/services/Kik.service' - -import config from '../../config' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -const url = 'https://bonjour.com' -const baseUrl = 'http://localhost:8080' -const channelPayload = { - type: 'slack', - isActivated: true, - slug: 'slack-test', - token: 'test-token', -} - -describe('Channel controller', () => { - let bot = {} - before(async () => bot = await Bot({ url }).save()) - after(async () => await Bot.remove({})) - - describe('POST: create a channel', () => { - afterEach(async () => Promise.all([Channel.remove({})])) - - it ('should be a 201', async () => { - const res = await chai.request(baseUrl).post(`/bots/${bot._id}/channels`) - .send(channelPayload) - const { message, results } = res.body - - assert.equal(res.status, 201) - assert.equal(results.type, channelPayload.type) - assert.equal(results.isActivated, channelPayload.isActivated) - assert.equal(results.slug, channelPayload.slug) - assert.equal(results.token, channelPayload.token) - assert.equal(message, 'Channel successfully created') - }) - - it ('should be a 404 with no bots', async () => { - try { - const newBot = await new Bot({ url }).save() - await Bot.remove({ _id: newBot._id }) - const res = await chai.request(baseUrl).post(`/bots/${newBot._id}/channels`) - .send(channelPayload) - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Bot not found') - } - }) - - it ('should be a 409 with a slug already existing', async () => { - const payload = { type: 'slack', isActivated: true, slug: 'test', token: 'test-token' } - const channel = await new Channel({ ...payload, bot: bot._id }).save() - try { - await chai.request(baseUrl).post(`/bots/${bot._id}/channels`).send(payload) - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 409) - assert.equal(res.body.message, 'Channel slug already exists') - } - }) -}) - - describe('GET: get a bot\'s channels', () => { - afterEach(async () => Channel.remove({})) - - it ('should be a 200 with channels', async () => { - await Promise.all([ - new Channel({ bot: bot._id, ...channelPayload }).save(), - new Channel({ bot: bot._id, ...channelPayload }).save(), - ]) - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/channels`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.length, 2) - assert.equal(message, 'Channels successfully rendered') - }) - - it ('should be a 200 with no channels', async () => { - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/channels`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.length, 0) - assert.equal(message, 'No channels') - }) - }) - - describe('GET: get a bot\'s channel', () => { - afterEach(async () => Channel.remove({})) - - it('should be a 200 with a channel', async () => { - const channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/channels/${channel.slug}`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.id, channel._id) - assert.equal(results.slug, channel.slug) - assert.equal(message, 'Channel successfully rendered') - }) - - it('should be a 404 with no channels', async () => { - try { - const channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - await Channel.remove({}) - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/channels/${channel.slug}`).send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Channel not found') - } - }) - }) - - describe('PUT: update a channel', () => { - afterEach(async () => Channel.remove({})) - - it('should be a 200 with a channel', async () => { - const channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - const res = await chai.request(baseUrl).put(`/bots/${bot._id}/channels/${channel.slug}`).send({ slug: 'updatedSlug' }) - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.slug, 'updatedSlug') - assert.equal(message, 'Channel successfully updated') - }) - - it('should be a 404 with no channels', async () => { - try { - const channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - await Channel.remove({}) - const res = await chai.request(baseUrl).put(`/bots/${bot._id}/channels/${channel.slug}`).send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Channel not found') - } - }) - }) - - describe('DELETE: delete a channel', () => { - it('should be a 200 with a channel', async () => { - const channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - bot.channels.push(channel._id) - await bot.save() - const res = await chai.request(baseUrl).del(`/bots/${bot._id}/channels/${channel.slug}`).send({ slug: 'updatedSlug' }) - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results, null) - assert.equal(message, 'Channel successfully deleted') - }) - - it('should be a 404 with no channels', async () => { - try { - const channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - await Channel.remove({}) - const res = await chai.request(baseUrl).del(`/bots/${bot._id}/channels/${channel.slug}`).send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Channel not found') - } - }) - }) -}) diff --git a/test/controllers/Conversations.controllers.tests.js b/test/controllers/Conversations.controllers.tests.js deleted file mode 100644 index 60c5e7a..0000000 --- a/test/controllers/Conversations.controllers.tests.js +++ /dev/null @@ -1,150 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import config from '../../config' - -import model from '../../src/models' -import Bot from '../../src/models/Bot.model' -import Channel from '../../src/models/Channel.model' -import Conversation from '../../src/models/Conversation.model' - -const expect = chai.expect -const assert = chai.assert - -chai.use(chaiHttp) - -const url = 'http://bonjour.com' -const baseUrl = 'http://localhost:8080' -const channelPayload = { - isActivated: true, - slug: 'test', - type: 'slack', - token: 'test', -} -const conversationPayload = { - isActive: true, - chatId: 'test', -} - -describe('Conversation controller', () => { - let bot = {} - let channel = {} - let conversation = {} - before(async () => { - bot = await new Bot({ url }).save() - channel = await new Channel({ bot: bot._id, ...channelPayload }).save() - conversation = await new Conversation({ bot: bot._id, channel: channel._id, ...conversationPayload }).save() - bot.conversations.push(conversation._id) - await bot.save() - }) - after(async () => await Promise.all([ - Bot.remove({}), - Channel.remove({}), - Conversation.remove({}), - ])) - - describe('GET: get a bot conversations', () => { - it('should be a 200 with conversations', async () => { - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/conversations`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.length, 1) - assert.equal(message, 'Conversations successfully rendered') - }) - - it('should be a 200 with no conversations', async () => { - const bot = await new Bot({ url }).save() - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/conversations`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.length, 0) - assert.equal(message, 'No conversations') - }) - - it('should be a 404 with no bot', async () => { - try { - const bot = await new Bot({ url }).save() - await Bot.remove({ _id: bot._id }) - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/conversations`).send() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Bot not found') - } - }) - }) - - describe('GET: get a conversation', () => { - it('should be a 200 with a conversation', async () => { - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/conversations/${conversation._id}`).send() - const { message, results } = res.body - - assert.equal(res.status, 200) - assert.equal(results.id, conversation._id) - assert.equal(message, 'Conversation successfully rendered') - }) - - it('should be a 404 with no bot', async () => { - try { - const bot = await new Bot({ url }).save() - await Bot.remove({ _id: bot._id }) - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/conversations/${conversation._id}`).send() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Bot not found') - } - }) - - it('should be a 404 with no bot', async () => { - try { - const conversation = await new Conversation({ bot: bot._id, channel: channel._id, ...conversationPayload }).save() - await Conversation.remove({ _id: conversation._id }) - const res = await chai.request(baseUrl).get(`/bots/${bot._id}/conversations/${conversation._id}`).send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Conversation not found') - } - }) - }) - - describe('DELETE: delete a conversation', () => { - it('should be a 204 with a conversation', async () => { - const conversation = await new Conversation({ bot: bot._id, channel: channel._id, ...conversationPayload }).save() - const res = await chai.request(baseUrl).delete(`/bots/${bot._id}/conversations/${conversation._id}`).send() - const { message, results } = res.body - - assert.equal(res.status, 204) - assert.equal(results, null) - assert.equal(message, null) - }) - - it('should be a 404 with no conversation', async () => { - try { - const conversation = await new Conversation({ bot: bot._id, channel: channel._id, ...conversationPayload }).save() - await Conversation.remove({ _id: conversation._id }) - const res = await chai.request(baseUrl).delete(`/bots/${bot._id}/conversations/${conversation._id}`).send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 404) - assert.equal(results, null) - assert.equal(message, 'Conversation not found') - } - }) - }) -}) diff --git a/test/controllers/Messages.controller.tests.js b/test/controllers/Messages.controller.tests.js deleted file mode 100644 index 375e1fb..0000000 --- a/test/controllers/Messages.controller.tests.js +++ /dev/null @@ -1,136 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -import Bot from '../../src/models/Bot.model' -import Conversation from '../../src/models/Conversation.model' -import Channel from '../../src/models/Channel.model' - -import MessagesController from '../../src/controllers/Messages.controller' - -import sinon from 'sinon' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -function clearDB () { - for (const i in mongoose.connection.collections) { - if (mongoose.connection.collections.hasOwnProperty(i)) { - mongoose.connection.collections[i].remove() - } - } -} - -const url = 'https://bonjour.com' -const baseUrl = 'http://localhost:8080' -const updatedUrl = 'https://aurevoir.com' - -describe('Messages controller', () => { - describe('postMessages', () => { - after(async () => clearDB()) - - it('should send messages to all bots conversation', async () => { - let bot = await new Bot({ url: 'url' }).save() - const channel1 = await new Channel({ - bot: bot, - slug: 'channel-1', - type: 'slack', - token: 'abcd', - isActivated: true, - }).save() - const convers1 = await new Conversation({ - channel: channel1, - bot: bot, - isActive: true, - chatId: '123', - }).save() - channel1.conversations = [convers1._id] - await channel1.save() - - const channel2 = await new Channel({ - bot: bot, - slug: 'channel-2', - type: 'slack', - token: 'abcd', - isActivated: true, - }).save() - const convers2 = await new Conversation({ - channel: channel2, - bot: bot, - isActive: true, - chatId: '1234', - }).save() - channel2.conversations = [convers2._id] - await channel2.save() - - bot.channels = [channel1, channel2] - bot.conversations = [convers1, convers2] - await bot.save() - - // With valid json parameter - let stub = sinon.stub(MessagesController, 'postToConversation', () => { true }) - let res = await chai.request(baseUrl).post(`/bots/${bot._id.toString()}/messages`).send({ - messages: [{ - type: 'text', - content: 'Hello' - }], - }) - expect(res.status).to.equal(201) - expect(res.body.message).to.equal('Messages successfully posted') - expect(stub.callCount).to.equal(2) - stub.restore() - - // With valid string parameter - stub = sinon.stub(MessagesController, 'postToConversation', () => { true }) - res = await chai.request(baseUrl).post(`/bots/${bot._id.toString()}/messages`).send({ - messages: JSON.stringify([{ type: 'text', content: 'Hello' }]), - }) - expect(res.status).to.equal(201) - expect(res.body.message).to.equal('Messages successfully posted') - expect(stub.callCount).to.equal(2) - stub.restore() - - // With invalid string parameter - stub = sinon.stub(MessagesController, 'postToConversation', () => { throw new Error('error') }) - try { - res = await chai.request(baseUrl).post(`/bots/${bot._id.toString()}/messages`).send({ - messages: '[,{ "type": "text",, "content": "Hello" }]]' - }) - should.fail() - } catch (err) { - res = err.response - const { message, results } = res.body - - expect(res.status).to.equal(400) - expect(res.body.message).to.equal("Invalid 'messages' parameter") - expect(stub.callCount).to.equal(0) - } finally { - stub.restore() - } - - // With error in postToConversation - stub = sinon.stub(MessagesController, 'postToConversation', () => { throw new Error('error') }) - try { - res = await chai.request(baseUrl).post(`/bots/${bot._id.toString()}/messages`).send({ - messages: [{ - type: 'text', - content: 'Hello' - }] - }) - should.fail() - } catch (err) { - res = err.response - const { message, results } = res.body - - expect(res.status).to.equal(500) - expect(res.body.message).to.equal('Error while posting message') - expect(stub.callCount).to.equal(1) - } finally { - stub.restore() - } - }) - }) -}) diff --git a/test/controllers/Participants.controller.tests.js b/test/controllers/Participants.controller.tests.js deleted file mode 100644 index 3350346..0000000 --- a/test/controllers/Participants.controller.tests.js +++ /dev/null @@ -1,463 +0,0 @@ -import mongoose from 'mongoose' -import chai from 'chai' -import chaiHttp from 'chai-http' - -import Bot from '../../src/models/Bot.model.js' -import Channel from '../../src/models/Channel.model.js' -import Conversation from '../../src/models/Conversation.model.js' -import Participant from '../../src/models/Participant.model.js' - -chai.use(chaiHttp) - -const expect = chai.expect - -let bot = null -let channel = null -let conversation1 = null -let conversation2 = null -let participant1 = null -let participant2 = null -let participant3 = null -let participant4 = null - -function clearDB () { - for (const i in mongoose.connection.collections) { - if (mongoose.connection.collections.hasOwnProperty(i)) { - mongoose.connection.collections[i].remove() - } - } -} - -describe('Participant controller', () => { - describe('should list bot participants', () => { - describe('with no participant', () => { - before(done => { - bot = new Bot() - channel = new Channel() - conversation1 = new Conversation() - - bot.url = 'http://fallback.fr' - - channel.bot = bot._id - channel.slug = 'kik-1' - channel.type = 'kik' - channel.isActivated = true - - bot.channels.push(channel._id) - - conversation1.channel = channel._id - conversation1.bot = bot._id - conversation1.isActive = true - conversation1.chatId = 'myChatId' - - bot.conversations.push(conversation1._id) - - conversation1.save(() => { - channel.save(() => { - bot.save(() => { - done() - }) - }) - }) - }) - - after(done => { - bot = null - channel = null - conversation1 = null - clearDB() - done() - }) - - it('should be a 400 with a not valid bot_id', done => { - chai.request('http://localhost:8080') - .get(`/bots/1/participants`) - .send() - .end((err, res) => { - chai.should(err).exist - chai.expect(res.status).to.equal(400) - chai.expect(res.body.results).to.equal(null) - chai.expect(res.body.message).to.equal('Parameter bot_id is invalid') - done() - }) - }) - - it('should be a 200', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants`) - .send() - .end((err, res) => { - chai.should(err).not.exist - chai.expect(res.status).to.equal(200) - chai.expect(res.body.results).to.be.an('array') - chai.expect(res.body.results.length).to.equal(0) - chai.expect(res.body.message).to.equal('No participants') - done() - }) - }) - }) - - describe('with many conversations and many participants', () => { - before(done => { - bot = new Bot() - channel = new Channel() - conversation1 = new Conversation() - conversation2 = new Conversation() - participant1 = new Participant() - participant2 = new Participant() - participant3 = new Participant() - participant4 = new Participant() - - bot.url = 'http://fallback.fr' - - channel.bot = bot._id - channel.slug = 'kik-1' - channel.type = 'kik' - channel.isActivated = true - - bot.channels.push(channel._id) - - conversation1.channel = channel._id - conversation1.bot = bot._id - conversation1.isActive = true - conversation1.chatId = 'myChatId' - - bot.conversations.push(conversation1._id) - - conversation2.channel = channel._id - conversation2.bot = bot._id - conversation2.isActive = true - conversation2.chatId = 'myChatId2' - - bot.conversations.push(conversation2._id) - - participant1.isBot = true - - conversation1.participants.push(participant1._id) - - participant2.isBot = false - - - conversation1.participants.push(participant2._id) - - participant3.isBot = true - - conversation2.participants.push(participant3._id) - - participant4.isBot = false - - - conversation2.participants.push(participant4._id) - - participant1.save(() => { - participant2.save(() => { - participant3.save(() => { - participant4.save(() => { - conversation1.save(() => { - conversation2.save(() => { - channel.save(() => { - bot.save(() => { - done() - }) - }) - }) - }) - }) - }) - }) - }) - }) - - after(done => { - bot = null - channel = null - conversation1 = null - conversation2 = null - participant1 = null - participant2 = null - participant3 = null - participant4 = null - clearDB() - done() - }) - - it('should be a 400 with a not valid bot_id', done => { - chai.request('http://localhost:8080') - .get(`/bots/1/participants`) - .send() - .end((err, res) => { - chai.should(err).exist - chai.expect(res.status).to.equal(400) - chai.expect(res.body.results).to.equal(null) - chai.expect(res.body.message).to.equal('Parameter bot_id is invalid') - done() - }) - }) - - it('should be a 200', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants`) - .send() - .end((err, res) => { - chai.should(err).not.exist - chai.expect(res.status).to.equal(200) - chai.expect(res.body.results).to.be.an('array') - chai.expect(res.body.results.length).to.equal(4) - chai.expect(res.body.results[0]).to.be.an('object') - chai.expect(res.body.results[0].id.toString()).to.equal(participant1._id.toString()) - chai.expect(res.body.results[0].isBot).to.equal(participant1.isBot) - chai.expect(res.body.results[1]).to.be.an('object') - chai.expect(res.body.results[1].id.toString()).to.equal(participant2._id.toString()) - chai.expect(res.body.results[1].isBot).to.equal(participant2.isBot) - chai.expect(res.body.results[2]).to.be.an('object') - chai.expect(res.body.results[2].id.toString()).to.equal(participant3._id.toString()) - chai.expect(res.body.results[2].isBot).to.equal(participant3.isBot) - chai.expect(res.body.results[3]).to.be.an('object') - chai.expect(res.body.results[3].id.toString()).to.equal(participant4._id.toString()) - chai.expect(res.body.results[3].isBot).to.equal(participant4.isBot) - chai.expect(res.body.message).to.equal('Participants successfully rendered') - done() - }) - }) - }) - }) - - describe('should index bot participants', () => { - describe('with no participant', () => { - before(done => { - bot = new Bot() - channel = new Channel() - conversation1 = new Conversation() - - bot.url = 'http://fallback.fr' - - channel.bot = bot._id - channel.slug = 'kik-1' - channel.type = 'kik' - channel.isActivated = true - - bot.channels.push(channel._id) - - conversation1.channel = channel._id - conversation1.bot = bot._id - conversation1.isActive = true - conversation1.chatId = 'myChatId' - - bot.conversations.push(conversation1._id) - - conversation1.save(() => { - channel.save(() => { - bot.save(() => { - done() - }) - }) - }) - }) - - after(done => { - bot = null - channel = null - conversation1 = null - clearDB() - done() - }) - - it('should be a 400 with a not valid bot_id', done => { - chai.request('http://localhost:8080') - .get(`/bots/1/participants/1`) - .send() - .end((err, res) => { - chai.should(err).exist - chai.expect(res.status).to.equal(400) - chai.expect(res.body.results).to.equal(null) - chai.expect(res.body.message).to.equal('Parameter bot_id is invalid') - done() - }) - }) - - it('should be a 400 with a not valid participant_id', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/1`) - .send() - .end((err, res) => { - chai.should(err).exist - chai.expect(res.status).to.equal(400) - chai.expect(res.body.results).to.equal(null) - chai.expect(res.body.message).to.equal('Parameter participant_id is invalid') - done() - }) - }) - - it('should be a 404 with a not valid participant_id', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/507f1f77bcf86cd799439011`) - .send() - .end((err, res) => { - chai.should(err).exist - chai.expect(res.status).to.equal(404) - chai.expect(res.body.results).to.equal(null) - chai.expect(res.body.message).to.equal('Participant not found') - done() - }) - }) - }) - - describe('with many conversations and many participants', () => { - before(done => { - bot = new Bot() - channel = new Channel() - conversation1 = new Conversation() - conversation2 = new Conversation() - participant1 = new Participant() - participant2 = new Participant() - participant3 = new Participant() - participant4 = new Participant() - - bot.url = 'http://fallback.fr' - - channel.bot = bot._id - channel.slug = 'kik-1' - channel.type = 'kik' - channel.isActivated = true - - bot.channels.push(channel._id) - - conversation1.channel = channel._id - conversation1.bot = bot._id - conversation1.isActive = true - conversation1.chatId = 'myChatId' - - bot.conversations.push(conversation1._id) - - conversation2.channel = channel._id - conversation2.bot = bot._id - conversation2.isActive = true - conversation2.chatId = 'myChatId2' - - bot.conversations.push(conversation2._id) - - participant1.isBot = true - - conversation1.participants.push(participant1._id) - - participant2.isBot = false - - - conversation1.participants.push(participant2._id) - - participant3.isBot = true - - conversation2.participants.push(participant3._id) - - participant4.isBot = false - - - conversation2.participants.push(participant4._id) - - participant1.save(() => { - participant2.save(() => { - participant3.save(() => { - participant4.save(() => { - conversation1.save(() => { - conversation2.save(() => { - channel.save(() => { - bot.save(() => { - done() - }) - }) - }) - }) - }) - }) - }) - }) - }) - - after(done => { - bot = null - channel = null - conversation1 = null - conversation2 = null - participant1 = null - participant2 = null - participant3 = null - participant4 = null - clearDB() - done() - }) - - it('should be a 404 with a not found participant_id', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/507f191e810c19729de860ea`) - .send() - .end((err, res) => { - chai.should(err).exist - chai.expect(res.status).to.equal(404) - chai.expect(res.body.results).to.equal(null) - chai.expect(res.body.message).to.equal('Participant not found') - done() - }) - }) - - it('should be a 200', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/${participant1._id}`) - .send() - .end((err, res) => { - chai.should(err).not.exist - chai.expect(res.status).to.equal(200) - chai.expect(res.body.results).to.be.an('object') - chai.expect(res.body.results.id.toString()).to.equal(participant1._id.toString()) - chai.expect(res.body.results.isBot).to.equal(participant1.isBot) - chai.expect(res.body.message).to.equal('Participant successfully rendered') - done() - }) - }) - - it('should be a 200', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/${participant2._id}`) - .send() - .end((err, res) => { - chai.should(err).not.exist - chai.expect(res.status).to.equal(200) - chai.expect(res.body.results).to.be.an('object') - chai.expect(res.body.results.id.toString()).to.equal(participant2._id.toString()) - chai.expect(res.body.results.isBot).to.equal(participant2.isBot) - chai.expect(res.body.message).to.equal('Participant successfully rendered') - done() - }) - }) - - it('should be a 200', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/${participant3._id}`) - .send() - .end((err, res) => { - chai.should(err).not.exist - chai.expect(res.status).to.equal(200) - chai.expect(res.body.results).to.be.an('object') - chai.expect(res.body.results.id.toString()).to.equal(participant3._id.toString()) - chai.expect(res.body.results.isBot).to.equal(participant3.isBot) - chai.expect(res.body.message).to.equal('Participant successfully rendered') - done() - }) - }) - - it('should be a 200', done => { - chai.request('http://localhost:8080') - .get(`/bots/${bot._id}/participants/${participant4._id}`) - .send() - .end((err, res) => { - chai.should(err).not.exist - chai.expect(res.status).to.equal(200) - chai.expect(res.body.results).to.be.an('object') - chai.expect(res.body.results.id.toString()).to.equal(participant4._id.toString()) - chai.expect(res.body.results.isBot).to.equal(participant4.isBot) - chai.expect(res.body.message).to.equal('Participant successfully rendered') - done() - }) - }) - }) - }) -}) diff --git a/test/controllers/application.js b/test/controllers/application.js new file mode 100644 index 0000000..c67bbe6 --- /dev/null +++ b/test/controllers/application.js @@ -0,0 +1,25 @@ +import expect from 'expect.js' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import '../util/start_application' + +const agent = superagentPromise(superagent, Promise) + +describe('App Controller', () => { + + describe('GET /', () => { + it('should be 200', async () => { + const res = await agent.get(`${process.env.ROUTETEST}/v1`) + expect(res.status).to.be(200) + }) + }) + + describe('POST /', () => { + it('should be 200', async () => { + const res = await agent.post(`${process.env.ROUTETEST}/v1`) + expect(res.status).to.be(200) + }) + }) + +}) diff --git a/test/controllers/channels.js b/test/controllers/channels.js new file mode 100644 index 0000000..64ae6ab --- /dev/null +++ b/test/controllers/channels.js @@ -0,0 +1,334 @@ +import expect from 'expect.js' +import should from 'should' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import config from '../../config' +import channelFactory from '../factories/channel' +import connectorFactory from '../factories/connector' +import { Connector, Channel } from '../../src/models' +import '../util/start_application' +const agent = superagentPromise(superagent, Promise) + +let connector = null +let channel = null + +describe('Channels Controller', () => { + after(async () => { + await Connector.remove() + await Channel.remove() + }) + + // Channel Creation + + describe('Create', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with valid parameters', async () => { + connector = await connectorFactory.build() + const payload = { type: 'recastwebchat', slug: 'my-awesome-channel' } + const res = await agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send(payload) + const { results, message } = res.body + + expect(res.status).to.be(201) + expect(message).to.be('Channel successfully created') + expect(results.type).to.be(payload.type) + expect(results.slug).to.be(payload.slug) + }) + + it('should 400 with missing type', async () => { + try { + connector = await connectorFactory.build() + const payload = { slug: 'my-awesome-channel' } + await agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Parameter type is missing') + expect(results).to.be(null) + } + }) + + it('should 400 with invalid type', async () => { + try { + connector = await connectorFactory.build() + const payload = { slug: 'my-awesome-channel', type: 'yolo' } + await agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Parameter type is invalid') + expect(results).to.be(null) + } + }) + + it('should 400 with missing slug', async () => { + try { + connector = await connectorFactory.build() + const payload = { type: 'messenger' } + await agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Parameter slug is missing') + expect(results).to.be(null) + } + }) + + it('should 404 without connector', async () => { + try { + const payload = { type: 'recastwebchat', slug: 'yoloswag' } + await agent.post(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/channels`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + }) + + // Channel Update + + describe('Update', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with valid parameters', async () => { + connector = await connectorFactory.build() + channel = await channelFactory.build(connector) + const payload = { slug: 'my-awesome-channel-updated' } + const res = await agent.put(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels/${channel.slug}`) + .send(payload) + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Channel successfully updated') + expect(results.slug).to.be(payload.slug) + expect(results.id).to.be(channel._id) + expect(results.type).to.be(channel.type) + }) + + it('should 404 without connector', async () => { + try { + const payload = { slug: 'updated-slug' } + await agent.put(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/channels/lol`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + + it('should 404 without channel', async () => { + try { + connector = await connectorFactory.build() + const payload = { slug: 'updated-slug' } + await agent.put(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels/lol`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + // Channel Delete + + describe('DELETE', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with a channel', async () => { + connector = await connectorFactory.build() + channel = await channelFactory.build(connector) + const res = await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels/${channel.slug}`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Channel successfully deleted') + expect(results).to.be(null) + }) + + it('should 404 without connector', async () => { + try { + await agent.del(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/channels/lol`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + + it('should 404 without channel', async () => { + try { + connector = await connectorFactory.build() + await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels/lol`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + // Channel Show + + describe('SHOW', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with a channel', async () => { + connector = await connectorFactory.build() + channel = await channelFactory.build(connector) + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels/${channel.slug}`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Channel successfully rendered') + expect(results.type).to.be(channel.type) + expect(results.id).to.be(channel.id) + expect(results.slug).to.be(channel.slug) + }) + + it('should 404 without connector', async () => { + try { + await agent.get(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/channels/lol`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + + it('should 404 without channel', async () => { + try { + connector = await connectorFactory.build() + await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels/lol`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + // Channel Index + + describe('INDEX', () => { + afterEach(async () => { + Promise.all([ + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with channels', async () => { + connector = await connectorFactory.build() + channel = await channelFactory.build(connector) + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Channels successfully rendered') + expect(results.length).to.equal(1) + expect(results[0].id).to.equal(channel._id) + expect(results[0].slug).to.equal(channel.slug) + expect(results[0].type).to.equal(channel.type) + }) + + it('should 200 without channels', async () => { + connector = await connectorFactory.build() + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('No channels') + expect(results.length).to.equal(0) + }) + + it('should 404 without connector', async () => { + try { + await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + }) + +}) diff --git a/test/controllers/connectors.js b/test/controllers/connectors.js new file mode 100644 index 0000000..6315dec --- /dev/null +++ b/test/controllers/connectors.js @@ -0,0 +1,146 @@ +import expect from 'expect.js' +import should from 'should' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' +import '../util/start_application' +import { Connector } from '../../src/models' +import config from '../../config' +import connectorFactory from '../factories/Connector' + +let connector = null + +const agent = superagentPromise(superagent, Promise) + +describe('Connector Controller', () => { + beforeEach(async () => { + connector = await connectorFactory.build() + }) + + afterEach(async () => { + await Connector.remove() + }) + + it('should Create with valid parameters', async () => { + await Connector.remove() + const payload = { url: 'https://mynewconnector.com' } + const res = await agent.post(`${process.env.ROUTETEST}/v1/connectors`) + .send(payload) + const { results, message } = res.body + + expect(res.status).to.be(201) + expect(message).to.be('Connector successfully created') + expect(results.url).to.be('https://mynewconnector.com') + }) + + it('should not Create with missing url', async () => { + try { + await Connector.remove() + const payload = { } + await agent.post(`${process.env.ROUTETEST}/v1/connectors`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Parameter url is missing') + expect(results).to.be(null) + } + }) + + it('should not Create with invalid url', async () => { + try { + await Connector.remove() + const payload = { url: 'lol' } + await agent.post(`${process.env.ROUTETEST}/v1/connectors`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Parameter url is invalid') + expect(results).to.be(null) + } + }) + + it('should Update with valid parameters', async () => { + const payload = { url: 'https://myupdatedconnector.com' } + const res = await agent.put(`${process.env.ROUTETEST}/v1/connectors/${connector._id}`) + .send(payload) + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Connector successfully updated') + expect(results.url).to.be(payload.url) + }) + + it('should not Update with invalid url', async () => { + try { + const payload = { url: 'Invalidurl' } + await agent.put(`${process.env.ROUTETEST}/v1/connectors/${connector._id}`) + .send(payload) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Parameter url is invalid') + expect(results).to.be(null) + } + }) + + it('should Show a valid bot', async () => { + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}`) + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Connector successfully found') + expect(results.id).to.be(connector._id) + expect(results.url).to.be(connector.url) + expect(results.isTyping).to.be(connector.isTyping) + }) + + it('should not Show an invalid bot', async () => { + try { + await Connector.remove() + await agent.get(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8`) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + + it('should Delete a valid connector', async () => { + const res = await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}`) + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Connector successfully deleted') + expect(results).to.be(null) + }) + + it('should not delete an invalid bot', async () => { + try { + await Connector.remove() + await agent.del(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8`) + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + +}) diff --git a/test/controllers/conversations.js b/test/controllers/conversations.js new file mode 100644 index 0000000..45a4b23 --- /dev/null +++ b/test/controllers/conversations.js @@ -0,0 +1,197 @@ +import expect from 'expect.js' +import should from 'should' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' +import '../util/start_application' +import config from '../../config/test' +import channelFacto from '../factories/Channel' +import connectorFacto from '../factories/Connector' +import conversationFacto from '../factories/Conversation' +import { Connector, Channel, Conversation } from '../../src/models' + +const agent = superagentPromise(superagent, Promise) + +let connector = null +let channel = null +let conversation = null + +describe('Conversations controller', () => { + after(async () => { + await Promise.all([ + Conversation.remove(), + Connector.remove(), + Channel.remove(), + ]) + }) + + // Index conversations + + describe('GET', () => { + afterEach(async () => { + await Promise.all([ + Conversation.remove(), + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with conversations', async () => { + connector = await connectorFacto.build() + channel = await channelFacto.build(connector) + conversation = await conversationFacto.build(connector, channel) + + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/conversations`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Conversations successfully found') + expect(results.length).to.be(1) + expect(results[0].id).to.equal(conversation._id) + }) + + it('should 200 without conversations', async () => { + connector = await connectorFacto.build() + channel = await channelFacto.build(connector) + + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/conversations`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('No conversations') + expect(results.length).to.be(0) + }) + + it('should 404 without connector', async () => { + try { + await agent.get(`${process.env.ROUTETEST}/v1/connectors/dec04e80-424d-4a9f-bb80-6d40511e246b/conversations`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + }) + + // Show a conversation + + describe('GET', () => { + afterEach(async () => { + await Promise.all([ + Conversation.remove(), + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 with a conversation', async () => { + connector = await connectorFacto.build() + channel = await channelFacto.build(connector) + conversation = await conversationFacto.build(connector, channel) + + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/conversations/${conversation._id}`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Conversation successfully found') + expect(results.id).to.equal(conversation._id) + }) + + it('should 404 without a conversation', async () => { + try { + connector = await connectorFacto.build() + await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/conversations/invalid_id`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Conversation not found') + expect(results).to.be(null) + } + }) + + it('should 404 without connector', async () => { + try { + await agent.get(`${process.env.ROUTETEST}/v1/connectors/dec04e80-424d-4a9f-bb80-6d40511e246/conversations/invalid_id`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + }) + + // Delete a conversation + + describe('DELETE', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + Conversation.remove(), + ]) + }) + + it('should 200 with a conversation', async () => { + connector = await connectorFacto.build() + channel = await channelFacto.build(connector) + conversation = await conversationFacto.build(connector, channel) + + const res = await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/conversations/${conversation._id}`) + .send() + const { results, message } = res.body + + expect(res.status).to.be(200) + expect(message).to.be('Conversation successfully deleted') + expect(results).to.equal(null) + }) + + it('should 404 without a conversation', async () => { + try { + connector = await connectorFacto.build() + await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/conversations/invalid_id`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Conversation not found') + expect(results).to.be(null) + } + }) + + it('should 404 without a connector', async () => { + try { + await agent.del(`${process.env.ROUTETEST}/v1/connectors/dec04e80-424d-4a9f-bb80-6d40511e246/conversations/invalid_id`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Connector not found') + expect(results).to.be(null) + } + }) + }) + +}) + diff --git a/test/controllers/messages.js b/test/controllers/messages.js new file mode 100644 index 0000000..e4d0e08 --- /dev/null +++ b/test/controllers/messages.js @@ -0,0 +1,8 @@ +import expect from 'expect.js' +import should from 'should' +import agent from 'superagent' + +describe('Messages Controller Testing', () => { + // DO NOT IMPLEMENT ANY TESTS FOR THIS CONTROLLER + // WITHOUT MOCKING EXTERNAL SERVICES +}) diff --git a/test/controllers/oauth.js b/test/controllers/oauth.js new file mode 100644 index 0000000..5773e00 --- /dev/null +++ b/test/controllers/oauth.js @@ -0,0 +1,8 @@ +import expect from 'expect.js' +import should from 'should' +import agent from 'superagent' + +describe('Oauth Controller Testing', () => { + // DO NOT IMPLEMENT ANY TESTS FOR THIS CONTROLLER + // WITHOUT MOCKING EXTERNAL SERVICES +}) diff --git a/test/controllers/participants.js b/test/controllers/participants.js new file mode 100644 index 0000000..e7bee54 --- /dev/null +++ b/test/controllers/participants.js @@ -0,0 +1,199 @@ +import expect from 'expect.js' +import agent from 'superagent' +import should from 'should' + +import config from '../../config/test' +import '../util/start_application' +import Connector from '../../src/models/connector' +import Conversation from '../../src/models/conversation' +import Participant from '../../src/models/participant' + +let connector = null +let conversation = null +let participant = null +let participant2 = null + +let connector2 = null +let conversation2 = null +let participant3 = null + +describe('Participants Controller Testing', () => { + describe('Get all connector\'s participants', () => { + describe('GET /participants', () => { + before(done => { + connector = new Connector() + connector2 = new Connector() + conversation = new Conversation() + conversation2 = new Conversation() + participant = new Participant() + participant2 = new Participant() + participant3 = new Participant() + + connector.url = 'http://myurl.com' + + conversation.channel = 'directline' + conversation.connector = connector._id + conversation.chatId = 'mychatid' + + participant.senderId = 'part1_senderId', + participant.data = { name: 'part1' }, + participant.isBot = true, + participant.conversation = conversation._id + + participant2.senderId = 'part2_senderId', + participant2.data = { name: 'part2' }, + participant2.isBot = false, + participant2.conversation = conversation._id + + connector2.url = 'http://myurl2.com' + + conversation2.channel = 'directline' + conversation2.connector = connector2._id + conversation2.chatId = 'mychatid2' + + participant3.senderId = 'part3_senderId', + participant3.data = { name: 'part3' }, + participant3.isBot = true, + participant3.conversation = conversation2._id + + connector.save(() => { + connector2.save(() => { + conversation.save(() => { + conversation2.save(() => { + participant.save(() => { + participant2.save(() => { + participant3.save(() => { + done() + }) + }) + }) + }) + }) + }) + }) + }) + + after(done => { + connector = null + connector2 = null + conversation = null + conversation2 = null + participant = null + participant2 = null + participant3 = null + Connector.remove(() => { + Conversation.remove(() => { + Participant.remove(() => { + done() + }) + }) + }) + }) + + it('should work with developer_token', (done) => { + agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector2._id}/participants`) + .send() + .end((err, result) => { + should.not.exist(err) + expect(result.status).to.be(200) + expect(result.body.results).not.to.be(null) + expect(result.body.results.length).to.be(1) + expect(result.body.results[0].id).to.be(participant3._id) + expect(result.body.results[0].isBot).to.be(participant3.isBot) + expect(result.body.results[0].senderId).to.be(participant3.senderId) + + done() + }) + }) + }) + }) + + describe('Get a connector\'s participant', () => { + describe('GET /participants/:participant_id', () => { + before(done => { + connector = new Connector() + connector2 = new Connector() + conversation = new Conversation() + conversation2 = new Conversation() + participant = new Participant() + participant2 = new Participant() + participant3 = new Participant() + + connector.url = 'http://myurl.com' + + conversation.channel = 'directline' + conversation.connector = connector._id + conversation.chatId = 'mychatid' + + participant.senderId = 'part1_senderId' + participant.data = { name: 'part1' } + participant.isBot = true + participant.conversation = conversation._id + + participant2.senderId = 'part2_senderId' + participant2.data = { name: 'part2' } + participant2.isBot = false + participant2.conversation = conversation2._id + + connector2.url = 'http://myurl2.com' + + conversation2.channel = 'directline' + conversation2.connector = connector2._id + conversation2.chatId = 'mychatid2' + + participant3.senderId = 'part3_senderId' + participant3.data = { name: 'part3' } + participant3.isBot = true + participant3.conversation = conversation2._id + + connector.save(() => { + connector2.save(() => { + conversation.save(() => { + conversation2.save(() => { + participant.save(() => { + participant2.save(() => { + participant3.save(() => { + done() + }) + }) + }) + }) + }) + }) + }) + }) + + after(done => { + connector = null + connector2 = null + conversation = null + conversation2 = null + participant = null + participant2 = null + participant3 = null + Connector.remove(() => { + Conversation.remove(() => { + Participant.remove(() => { + done() + }) + }) + }) + }) + + it('should work with developer_token', (done) => { + agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector2._id}/participants/${participant3._id}`) + .send() + .end((err, result) => { + should.not.exist(err) + expect(result.status).to.be(200) + expect(result.body.results).not.to.be(null) + expect(result.body.results.id).to.be(participant3._id) + expect(result.body.results.isBot).to.be(participant3.isBot) + expect(result.body.results.senderId).to.be(participant3.senderId) + + done() + }) + }) + }) + }) +}) diff --git a/test/controllers/persistent_menus.js b/test/controllers/persistent_menus.js new file mode 100644 index 0000000..03a1dfd --- /dev/null +++ b/test/controllers/persistent_menus.js @@ -0,0 +1,264 @@ +import expect from 'expect.js' +import should from 'should' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' +import '../util/start_application' +import connectorFacto from '../factories/Connector' +import persistentmenuFacto from '../factories/Persistent_menu' +import { Connector, Channel, PersistentMenu } from '../../src/models' + +const agent = superagentPromise(superagent, Promise) + +let connector = null + +describe('Persistent menus controller', () => { + + describe('Create', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + PersistentMenu.remove(), + ]) + }) + + it('should 200 with valid parameters', async () => { + connector = await connectorFacto.build() + + const menu = { + menu: { + call_to_actions: [ + { + type: 'web_url', + payload: 'http://google.com', + title: 'Lien vers Google en allemand', + }], + }, + language: 'de', + } + const res = await agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus`) + .send(menu) + const { results, message } = res.body + + expect(res.status).to.be(201) + expect(message).to.be('PersistentMenu successfully created') + expect(JSON.stringify(results.menu)).to.be(JSON.stringify(menu.menu)) + expect(results.language).to.be(menu.menu.language) + }) + + it('should 409 if menu already exists for a language', async () => { + connector = await connectorFacto.build() + + const menu = { + menu: {}, + language: 'de', + } + await persistentmenuFacto.build(connector, { locale: 'de' }) + try { + await agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus`) + .send(menu) + + should.fail() + } catch (err) { + const { message } = err.response.body + expect(err.status).to.be(409) + expect(message).to.be('A persistent menu already exists for this language') + } + }) + + it('should 404 with no connector', async () => { + try { + await agent.post(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/persistentmenus`) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('Connector not found') + expect(err.status).to.be(404) + } + }) + }) + + describe('Get', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + PersistentMenu.remove(), + ]) + }) + it('should 200 with one menu', async () => { + connector = await connectorFacto.build() + const data = { + menu: { + some: 'menu', + }, + language: 'de', + } + await persistentmenuFacto.build(connector, { menu: data.menu, locale: data.language }) + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus/de`) + + const { results, message } = res.body + expect(message).to.be('PersistentMenu successfully rendered') + expect(JSON.stringify(results.menu)).to.be(JSON.stringify(data.menu)) + expect(results.locale).to.be(data.language) + }) + + it('should 200 with multiple menus', async () => { + connector = await connectorFacto.build() + await persistentmenuFacto.build(connector, { locale: 'en' }) + await persistentmenuFacto.build(connector, { locale: 'fr' }) + await persistentmenuFacto.build(connector, { locale: 'de' }) + + const res = await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus`) + + const { results, message } = res.body + expect(results.length).to.be(3) + expect(message).to.be('Persistent menus successfully rendered') + }) + + it('should 404 with no menu for this language', async () => { + connector = await connectorFacto.build() + try { + await agent.get(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus/de`) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('PersistentMenu not found') + expect(err.status).to.be(404) + } + }) + + it('should 404 with no connector', async () => { + try { + await agent.get(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/persistentmenus/de`) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('Connector not found') + expect(err.status).to.be(404) + } + }) + + }) + + describe('Update', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + PersistentMenu.remove(), + ]) + }) + + it('should 200 with valid parameters', async () => { + connector = await connectorFacto.build() + await persistentmenuFacto.build(connector, { locale: 'en' }) + + const data = { + menu: { + awesome: 'menu', + }, + } + const res = await agent.put(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus/en`) + .send(data) + + const { results, message } = res.body + expect(res.status).to.be(200) + expect(message).to.be('PersistentMenu successfully updated') + expect(JSON.stringify(results.menu)).to.be(JSON.stringify(data.menu)) + }) + + it('should 404 with no connector', async () => { + try { + const data = { menu: {} } + await agent.put(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/persistentmenus/de`) + .send(data) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('Connector not found') + expect(err.status).to.be(404) + } + }) + }) + + describe('DeleteAll', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + PersistentMenu.remove(), + ]) + }) + + it('should 200', async () => { + connector = await connectorFacto.build() + + await persistentmenuFacto.build(connector, { locale: 'de' }) + await persistentmenuFacto.build(connector, { locale: 'en' }) + + const res = await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus`) + + const { results, message } = res.body + expect(res.status).to.be(200) + expect(message).to.be('Persistent Menu successfully deleted') + expect(results).to.be(null) + }) + + it('should 404 with no connector', async () => { + try { + const data = { menu: {} } + await agent.del(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/persistentmenus`) + .send(data) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('Connector not found') + expect(err.status).to.be(404) + } + }) + }) + + describe('Delete', () => { + afterEach(async () => { + await Promise.all([ + Connector.remove(), + PersistentMenu.remove(), + ]) + }) + + it('should 200', async () => { + connector = await connectorFacto.build() + + await persistentmenuFacto.build(connector, { locale: 'de' }) + + const res = await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus/de`) + + const { results, message } = res.body + expect(res.status).to.be(200) + expect(message).to.be('Persistent Menu successfully deleted') + expect(results).to.be(null) + }) + + it('should 404 with no connector', async () => { + try { + const data = { menu: {} } + + await persistentmenuFacto.build(connector, { locale: 'de' }) + + await agent.del(`${process.env.ROUTETEST}/v1/connectors/d29dd4f8-aa8e-4224-81f9-c4da94db18b8/persistentmenus/de`) + .send(data) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('Connector not found') + expect(err.status).to.be(404) + } + }) + + it('should 404 with non-existing menu', async () => { + try { + const data = { menu: {} } + connector = await connectorFacto.build() + + await agent.del(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/persistentmenus/de`) + .send(data) + } catch (err) { + const { message } = err.response.body + expect(message).to.be('PersistentMenu not found') + expect(err.status).to.be(404) + } + }) + }) +}) diff --git a/test/controllers/webhooks.js b/test/controllers/webhooks.js new file mode 100644 index 0000000..8624de5 --- /dev/null +++ b/test/controllers/webhooks.js @@ -0,0 +1,564 @@ +import expect from 'expect.js' +import should from 'should' +import superagent from 'superagent' +import superagentPromise from 'superagent-promise' + +import channelFactory from '../factories/Channel' +import connectorFactory from '../factories/Connector' +import conversationFactory from '../factories/Conversation' +import messageFactory from '../factories/Message' +import participantFactory from '../factories/Participant' +import nock from 'nock' +import '../util/start_application' +import { Participant, Connector, Channel, Message, Conversation } from '../../src/models' + +const agent = superagentPromise(superagent, Promise) + +const expectPollResult = (res, messages) => { + const { results, message } = res.body + expect(res.status).to.be(200) + expect(message).to.be(`${messages.length} messages`) + expect(results.waitTime).to.be(0) + expect(results.messages.length).to.be(messages.length) + results.messages.forEach((msg, ind) => { + const expected = messages[ind] + expect(msg.attachment.content).to.be(expected) + expect(typeof msg.participant).to.be('object') + }) +} + +const sendUserMessage = (channel, conversation, text) => { + return agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}`) + .send({ chatId: conversation.chatId, message: { attachment: { type: 'text', content: text } } }) +} + +const sendBotMessages = (conversation, msgs) => { + return agent.post(`${process.env.ROUTETEST}/v1/connectors/${conversation.connector._id}/conversations/${conversation._id}/messages`) + .send({ messages: msgs.map(m => ({ type: 'text', content: m })) }) +} + +const pollConversation = (channel, conversation, last_message_id) => { + let pollUrl = `${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/poll` + if (last_message_id) { + pollUrl += `?last_message_id=${last_message_id}` + } + return agent.get(pollUrl) + .set({ Authorization: channel.token }) + .send() +} + +describe('Webhooks Controller Testing', () => { + afterEach(async () => { + Promise.all([ + Connector.remove(), + Channel.remove(), + Conversation.remove(), + Message.remove(), + Participant.remove(), + ]) + }) + + describe('POST forwardMessage', () => { + it('should 200 with a valid message', async () => { + const connector = await connectorFactory.build() + nock(connector.url).post('/').reply(200, {}) + const channel = await channelFactory.build(connector, { isActivated: true }) + const res = await agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}`) + .send({ + chatId: 123, + message: { attachment: { type: 'text', content: { value: 'a message' } } }, + }) + + expect(res.status).to.be(200) + }) + + it('should 400 with a deactivated channel', async () => { + try { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector, { isActivated: false }) + await agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Channel is not activated') + expect(results).to.be(null) + } + }) + + it('should 404 without a channel', async () => { + try { + await agent.post(`${process.env.ROUTETEST}/v1/webhook/lol`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + describe('GET subscribeWebhook', () => { + it('should 400 with an invalid webhook', async () => { + try { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector, { isActivated: false }) + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Unimplemented service method') + expect(results).to.be(null) + } + }) + + it('should 404 without a channel', async () => { + try { + await agent.get(`${process.env.ROUTETEST}/v1/webhook/lol`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + describe('POST createConversation', () => { + it('should 200 with a valid channel token', async () => { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + const res = await agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations`) + .set({ Authorization: channel.token }) + .send({ messages: [{ type: 'text', content: 'yolo' }] }) + const { message } = res.body + + expect(res.status).to.be(201) + expect(message).to.be('Conversation successfully created') + }) + + it('should 401 with an invalid channel token', async () => { + try { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + await agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(401) + expect(message).to.be('Request can not be processed with your role') + expect(results).to.be(null) + } + }) + + it('should 400 with an invalid channel type', async () => { + try { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector, { type: 'slack' }) + await agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(400) + expect(message).to.be('Invalid channel type') + expect(results).to.be(null) + } + }) + + it('should 404 without a channel', async () => { + try { + await agent.post(`${process.env.ROUTETEST}/v1/webhook/invalid_id/conversations`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + describe('GET getMessages', () => { + it('should 200 with a valid channel token', async () => { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + const participant = await participantFactory.build(conversation) + await messageFactory.build(conversation, participant) + await messageFactory.build(conversation, participant) + const response = await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/messages`) + .set({ Authorization: channel.token }) + .send() + + expect(response.status).to.be(200) + expect(response.body.message).to.be('Messages successfully fetched') + expect(response.body.results).to.have.length(2) + }) + + it('should 404 with wrong channel', async () => { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + + try { + await agent.get(`${process.env.ROUTETEST}/v1/webhook/randomWrongId/conversations/${conversation._id}/messages`) + .set({ Authorization: channel.token }) + .send() + } catch (err) { + expect(err.status).to.be(404) + expect(err.response.body.message).to.be('Channel not found') + expect(err.response.body.results).to.be(null) + } + }) + + it('should 404 with wrong conversation', async () => { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + + try { + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/randomWrongId/messages`) + .set({ Authorization: channel.token }) + .send() + } catch (err) { + expect(err.status).to.be(404) + expect(err.response.body.message).to.be('Conversation not found') + expect(err.response.body.results).to.be(null) + } + }) + + it('should 401 without token', async () => { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + + try { + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/messages`) + expect().fail('should have failed because of missing token') + } catch (err) { + if (!err.status) { throw err } + expect(err.status).to.be(401) + expect(err.response.body.message).to.be('Request can not be processed with your role') + expect(err.response.body.results).to.be(null) + } + }) + + it('should fail with wrong token', async () => { + const connector = await connectorFactory.build() + const channel = await channelFactory.build(connector) + const otherChannel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + + try { + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/messages`) + .set({ Authorization: otherChannel.token }) + .send() + + expect().fail('should have failed because of wrong token') + } catch (err) { + if (!err.status) { throw err } + expect(err.status).to.be(401) + expect(err.response.body.message).to.be('Request can not be processed with your role') + expect(err.response.body.results).to.be(null) + } + }) + }) + + describe('GET preferences', () => { + after(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + ]) + }) + + it('should 200 and return preferences', async () => { + const connector = await connectorFactory.build() + const preferences = { + accentColor: '#000000', + complementaryColor: '#100000', + botMessageColor: '#200000', + botMessageBackgroundColor: '#300000', + backgroundColor: '#400000', + headerLogo: '#500000', + headerTitle: '#600000', + botPicture: '#700000', + userPicture: '#800000', + onboardingMessage: '#900000', + expanderLogo: '#110000', + expanderTitle: '#120000', + conversationTimeToLive: 12, + characterLimit: 42, + userInputPlaceholder: 'Write a reply', + } + const channel = await channelFactory.build(connector, { type: 'webchat', ...preferences }) + + const res = await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/preferences`) + .set({ Authorization: channel.token }) + .send() + const { results, message } = res.body + expect(res.status).to.be(200) + expect(message).to.be('Preferences successfully rendered') + expect(results.accentColor).to.be(preferences.accentColor) + expect(results.complementaryColor).to.be(preferences.complementaryColor) + expect(results.botMessageColor).to.be(preferences.botMessageColor) + expect(results.botMessageBackgroundColor).to.be(preferences.botMessageBackgroundColor) + expect(results.backgroundColor).to.be(preferences.backgroundColor) + expect(results.headerLogo).to.be(preferences.headerLogo) + expect(results.headerTitle).to.be(preferences.headerTitle) + expect(results.botPicture).to.be(preferences.botPicture) + expect(results.userPicture).to.be(preferences.userPicture) + expect(results.onboardingMessage).to.be(preferences.onboardingMessage) + expect(results.expanderLogo).to.be(preferences.expanderLogo) + expect(results.expanderTitle).to.be(preferences.expanderTitle) + expect(results.conversationTimeToLive).to.be(preferences.conversationTimeToLive) + expect(results.characterLimit).to.be(preferences.characterLimit) + }) + + it('should 401 with an invalid channel token', async () => { + try { + const connector = await connectorFactory.build() + const preferences = {} + const channel = await channelFactory.build(connector, { type: 'webchat', ...preferences }) + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/preferences`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(401) + expect(message).to.be('Request can not be processed with your role') + expect(results).to.be(null) + } + }) + + it('should fail for wrong channel type (other than webchat)') + + it('should 404 with non-existing channel', async () => { + try { + const connector = await connectorFactory.build() + const preferences = {} + const channel = await channelFactory.build(connector, { type: 'webchat', ...preferences }) + await agent.get(`${process.env.ROUTETEST}/v1/webhook/non-existing/preferences`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + }) + + describe('GET poll', () => { + + let connector + beforeEach(async () => { + connector = await connectorFactory.build() + nock(connector.url).post('/').reply(200, {}) + }) + + afterEach(async () => { + await Promise.all([ + Connector.remove(), + Channel.remove(), + Conversation.remove(), + ]) + }) + + it('should 200 and return immediately with existing messages', async () => { + const channel = await channelFactory.build(connector, { type: 'webchat' }) + const conversation = await conversationFactory.build(connector, channel) + + await sendUserMessage(channel, conversation, 'yolo') + nock(connector.url).post('/').reply(200, {}) + await sendUserMessage(channel, conversation, 'lolz') + await new Promise((resolve) => setTimeout(resolve, 200)) + + const res = await pollConversation(channel, conversation) + expectPollResult(res, ['yolo', 'lolz']) + }) + + it('should 200 and return new incoming message from user', async () => { + const channel = await channelFactory.build(connector, { type: 'webchat' }) + const conversation = await conversationFactory.build(connector, channel) + + await sendUserMessage(channel, conversation, 'yolo') + + // make sure we receive old messages first + const res = await pollConversation(channel, conversation) + expectPollResult(res, ['yolo']) + const last_message_id = res.body.results.messages[0].id + + nock(connector.url).post('/').reply(200, {}) + await sendUserMessage(channel, conversation, 'haha') + + const res2 = await pollConversation(channel, conversation, last_message_id) + expectPollResult(res2, ['haha']) + }) + + it('should 200 and return new incoming message from bot', async () => { + const channel = await channelFactory.build(connector, { type: 'webchat' }) + const conversation = await conversationFactory.build(connector, channel) + + await sendUserMessage(channel, conversation, 'yolo') + + // make sure we receive old messages first + const res = await pollConversation(channel, conversation) + expectPollResult(res, ['yolo']) + const last_message_id = res.body.results.messages[0].id + + await sendBotMessages(conversation, ['megalol']) + + const res2 = await pollConversation(channel, conversation, last_message_id) + expectPollResult(res2, ['megalol']) + }) + + it('should 200 and return new incoming messages from bot and user', async () => { + const channel = await channelFactory.build(connector, { type: 'webchat' }) + const conversation = await conversationFactory.build(connector, channel) + + await sendUserMessage(channel, conversation, 'lool') + + const res = await pollConversation(channel, conversation) + expectPollResult(res, ['lool']) + const last_message_id = res.body.results.messages[0].id + + setTimeout(async () => { await sendBotMessages(conversation, ['mdr', 'ptdr']) }, 300) + + const res2 = await pollConversation(channel, conversation, last_message_id) + expectPollResult(res2, ['mdr']) + const last_message_id2 = res2.body.results.messages[0].id + + const res3 = await pollConversation(channel, conversation, last_message_id2) + expectPollResult(res3, ['ptdr']) + }) + + it('should 200 and forward conversation start', async () => { + const channel = await channelFactory.build(connector, { + type: 'webchat', + forwardConversationStart: true, + }) + nock(connector._doc.url).post('/').reply(200, { results: {}, message: 'Success' }) + const res = await agent.post(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations`) + .set({ Authorization: channel.token }) + .send() + const conversation = res.body.results + conversation._id = conversation.id + + const res2 = await pollConversation(channel, conversation) + expectPollResult(res2, ['']) + }) + + it('should ??? if conversation start fails') + + it('should not return when no message arrives', async () => { + const channel = await channelFactory.build(connector, { type: 'webchat' }) + const conversation = await conversationFactory.build(connector, channel) + + let over = false + pollConversation(channel, conversation).end(() => { over = true }) + await new Promise((resolve) => setTimeout(resolve, 600)) + expect(over).to.be(false) + }) + + it('should 401 with an invalid channel token', async () => { + try { + const channel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/poll`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(401) + expect(message).to.be('Request can not be processed with your role') + expect(results).to.be(null) + } + }) + + it('should 400 for invalid channel type', async () => { + const channel = await channelFactory.build(connector, { type: 'slackapp' }) + const conversation = await conversationFactory.build(connector, channel) + + try { + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/poll`) + .set({ Authorization: channel.token }) + .send() + + expect().fail() + } catch (err) { + if (!err.status) { throw err } + expect(err.status).to.be(400) + expect(err.response.body.message).to.be('Invalid channel type') + } + }) + + it('should 404 with non-existing channel', async () => { + try { + const channel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + await agent.get(`${process.env.ROUTETEST}/v1/webhook/not_existing/conversations/${conversation._id}/poll`) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Channel not found') + expect(results).to.be(null) + } + }) + + it('should fail for non-existing conversation') + + it('should 404 with non-existing last_message', async () => { + try { + const channel = await channelFactory.build(connector) + const conversation = await conversationFactory.build(connector, channel) + await agent.get(`${process.env.ROUTETEST}/v1/webhook/${channel._id}/conversations/${conversation._id}/poll?last_message_id=non-existing`) + .set({ Authorization: channel.token }) + .send() + + should.fail() + } catch (err) { + const { message, results } = err.response.body + + expect(err.status).to.be(404) + expect(message).to.be('Message not found') + expect(results).to.be(null) + } + }) + }) + +}) diff --git a/test/factories/channel.js b/test/factories/channel.js new file mode 100644 index 0000000..7f5f5b5 --- /dev/null +++ b/test/factories/channel.js @@ -0,0 +1,22 @@ +import crypto from 'crypto' +import Channel from '../../src/models/channel' + +const build = async (connector, opts = {}) => { + const data = { + connector: connector._id, + slug: opts.slug || crypto.randomBytes(20).toString('hex'), + type: opts.type || 'recastwebchat', + isActivated: opts.isActivated === false ? opts.isActivated : true, + token: crypto.randomBytes(20).toString('hex'), + } + Object.keys(opts).forEach(k => { + if (data[k] === undefined) { + data[k] = opts[k] + } + }) + const channel = new Channel(data) + + return channel.save() +} + +module.exports = { build } diff --git a/test/factories/connector.js b/test/factories/connector.js new file mode 100644 index 0000000..7ecda7d --- /dev/null +++ b/test/factories/connector.js @@ -0,0 +1,13 @@ +import crypto from 'crypto' +import Connector from '../../src/models/connector' + +const build = async (opts = {}) => { + const connector = new Connector({ + url: opts.url || `https://${crypto.randomBytes(20).toString('hex')}.fr`, + isActive: opts.isActive || true, + }) + + return connector.save() +} + +module.exports = { build } diff --git a/test/factories/conversation.js b/test/factories/conversation.js new file mode 100644 index 0000000..e73b5cd --- /dev/null +++ b/test/factories/conversation.js @@ -0,0 +1,14 @@ +import crypto from 'crypto' +import Conversation from '../../src/models/conversation' + +const build = async (connector, channel, opts = {}) => { + const conversation = new Conversation({ + channel, + connector, + chatId: opts.chatId || crypto.randomBytes(20).toString('hex'), + }) + + return conversation.save() +} + +module.exports = { build } diff --git a/test/factories/message.js b/test/factories/message.js new file mode 100644 index 0000000..dd4695f --- /dev/null +++ b/test/factories/message.js @@ -0,0 +1,15 @@ +import Message from '../../src/models/message' + +function build (conversation, participant, opts = {}) { + const message = new Message({ + attachement: opts.attachment || { type: 'text', content: 'this is a text message' }, + conversation: conversation._id, + participant: participant._id, + }) + + return message.save() +} + +export default { + build, +} diff --git a/test/factories/participant.js b/test/factories/participant.js new file mode 100644 index 0000000..5735b7d --- /dev/null +++ b/test/factories/participant.js @@ -0,0 +1,17 @@ +import Participant from '../../src/models/participant' + +function build (conversation, opts = {}) { + const participant = new Participant({ + conversation: conversation._id, + senderId: opts.senderId || 'senderId', + data: opts.data || { name: 'someParticipant' }, + isBot: opts.isBot || false, + type: opts.type || 'user', + }) + + return participant.save() +} + +export default { + build, +} diff --git a/test/factories/persistent_menu.js b/test/factories/persistent_menu.js new file mode 100644 index 0000000..15ad9fd --- /dev/null +++ b/test/factories/persistent_menu.js @@ -0,0 +1,20 @@ +import PersistentMenu from '../../src/models/persistent_menu' + +const build = async (connector, opts = {}) => { + const data = { + connector_id: connector._id, + menu: opts.menu || {}, + default: opts.default || false, + locale: opts.locale || 'en', + } + Object.keys(opts).forEach(k => { + if (data[k] === undefined) { + data[k] = opts[k] + } + }) + const persistentMenu = new PersistentMenu(data) + + return persistentMenu.save() +} + +module.exports = { build } diff --git a/test/mocks/http.js b/test/mocks/http.js deleted file mode 100644 index 746f576..0000000 --- a/test/mocks/http.js +++ /dev/null @@ -1,13 +0,0 @@ -import nock from 'nock' - -const scope = nock('https://api.kik.com') - -scope.post('/v1/config') -.reply(200, { - good: true, -}) - -scope.post('/v1/message') -.reply(200, { - good: true, -}) diff --git a/test/models/.gitkeep b/test/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/models/Bot.model.tests.js b/test/models/Bot.model.tests.js deleted file mode 100644 index f3fced0..0000000 --- a/test/models/Bot.model.tests.js +++ /dev/null @@ -1,104 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -import Bot from '../../src/models/Bot.model' - -const assert = require('chai').assert -const expect = chai.expect - -chai.use(chaiHttp) - -const fakeBot = { url: 'https://recast.ai' } -const fakeId = '57fe26383750e0379bee8aca' - -function clearDB () { - for (const i in mongoose.connection.collections) { - if (mongoose.connection.collections.hasOwnProperty(i)) { - mongoose.connection.collections[i].remove() - } - } -} - -describe('Bot Model', () => { - describe('Create bot', () => { - describe('Create a bot when db is empty:', () => { - after(async () => clearDB()) - - it('can create bot when no one exists', async () => { - const bot = await new Bot({ url: fakeBot.url }).save() - assert.equal(bot.url, fakeBot.url) - }) - }) - - describe('Create a bot when db have a bot', () => { - before(async () => new Bot({ url: fakeBot.url }).save()) - after(async () => clearDB()) - - it('can create bot when one exists', async () => { - const bot = await new Bot({ url: fakeBot.url }).save() - assert.equal(bot.url, fakeBot.url) - }) - }) - }) - - describe('List bot', () => { - describe('List bot when no one exist', () => { - after(async () => clearDB()) - - it('can list bot when no one exists', async () => { - const bots = await Bot.find({}).exec() - expect(bots).to.have.length(0) - }) - }) - - describe('List bots when two exist', () => { - before(async () => Promise.all([ - new Bot({ url: 'https://hello.com' }).save(), - new Bot({ url: 'https://bye.com' }).save(), - ])) - after(async () => clearDB()) - - it('can list bots when two exists', async () => { - const bots = await Bot.find({}) - expect(bots).to.have.length(2) - }) - }) - }) - - describe('Update bot', () => { - describe('can update bot when one bot exist', () => { - let bot = {} - before(async () => bot = await new Bot({ url: fakeBot.url }).save()) - after(async () => clearDB()) - - it('can updated bots when one bot exists', async () => { - const updatedBot = await Bot.findOneAndUpdate({ _id: bot._id }, { $set: { url: 'https://updated.com' } }, { new: true }).exec() - assert.equal(updatedBot.url, 'https://updated.com') - }) - }) - }) - - describe('Delete bot', () => { - describe('can delete bot when no bot exist', () => { - after(async () => clearDB()) - - it('can delete bot when id not found', async () => { - const deletedBots = await Bot.remove({ _id: fakeId }) - assert.equal(deletedBots.result.n, 0) - }) - }) - - describe('can delete bot when one bot exist', () => { - let bot = {} - before(async () => bot = await new Bot({ url: fakeBot.url }).save()) - after(async () => clearDB()) - - it('can delete bot when one exists', async () => { - const deletedBots = await Bot.remove({ _id: bot._id }) - assert.equal(deletedBots.result.n, 1) - }) - }) - }) - -}) diff --git a/test/models/Channel.model.tests.js b/test/models/Channel.model.tests.js deleted file mode 100644 index 57b2a0a..0000000 --- a/test/models/Channel.model.tests.js +++ /dev/null @@ -1,75 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' - -import Bot from '../../src/models/Bot.model' -import Channel from '../../src/models/Channel.model' - -const assert = require('chai').assert -const expect = chai.expect - -chai.use(chaiHttp) - -const fakeChannel = { - type: 'slack', - isActivated: true, - slug: 'slug-test1', - token: '1234567890', -} - -describe('Channel Model', () => { - let bot = {} - before(async () => bot = await new Bot({ url: 'https://bonjour.com' })) - - describe('Create Channel', () => { - after(async () => Channel.remove({})) - - it('can create a new Channel', async () => { - const channel = await new Channel({ bot: bot._id, ...fakeChannel }).save() - assert.equal(channel.type, fakeChannel.type) - assert.equal(channel.isActivated, fakeChannel.isActivated) - assert.equal(channel.slug, fakeChannel.slug) - assert.equal(channel.token, fakeChannel.token) - }) - }) - - describe('List Channel', () => { - after(async () => Channel.remove({})) - - it('can list channels when no one exists', async () => { - const channels = await Channel.find({}).exec() - expect(channels).to.have.length(0) - }) - - it('can list 1 channel', async () => { - await new Channel({ bot: bot._id, ...fakeChannel }).save() - const channels = await Channel.find({}).exec() - - expect(channels).to.have.length(1) - }) - }) - - describe('Update Channel', () => { - after(async () => Channel.remove({})) - - it('can update 1 channel', async () => { - const channel = await new Channel({ bot: bot._id, ...fakeChannel }).save() - const updatedChannel = await Channel.findOneAndUpdate({ _id: channel._id }, { $set: { isActivated: false } }, { new: true }).exec() - - assert.equal(updatedChannel.isActivated, false) - }) - }) - - describe('Delete Channel', () => { - after(async () => Channel.remove({})) - - it('can remove channel #2', async () => { - const [channel1] = await Promise.all([ - new Channel({ bot: bot._id, ...fakeChannel }).save(), - new Channel({ bot: bot._id, ...fakeChannel }).save(), - ]) - - const deletedChannel = await channel1.remove() - assert.equal(deletedChannel._id, channel1._id) - }) - }) -}) diff --git a/test/models/Conversation.model.tests.js b/test/models/Conversation.model.tests.js deleted file mode 100644 index 6b7be20..0000000 --- a/test/models/Conversation.model.tests.js +++ /dev/null @@ -1,95 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' - -import Bot from '../../src/models/Bot.model' -import Channel from '../../src/models/Channel.model' -import Conversation from '../../src/models/Conversation.model' - -const assert = chai.assert -const expect = chai.expect - -chai.use(chaiHttp) - -let bot = null -let channel = null -let payload = null - -describe('Conversation Model', () => { - before(async () => { - bot = await new Bot({ url: 'https://bonjour.com' }).save() - channel = await new Channel({ bot: bot._id, type: 'kik', slug: 'kik', isActivated: true }).save() - - payload = { - channel: channel._id, - bot: bot._id, - isActive: true, - chatId: 'testChatId', - } - }) - - after(async () => { - await Promise.all([ - Bot.remove({}), - Channel.remove({}), - Conversation.remove({}), - ]) - }) - - describe('Create conversation', () => { - after(async () => Conversation.remove({})) - - it('can create conversation when no one created', async () => { - const conversation = await new Conversation(payload) - - assert.equal(conversation.bot, payload.bot) - assert.equal(conversation.channel, payload.channel) - assert.equal(conversation.chatId, payload.chatId) - assert.equal(conversation.isActive, payload.isActive) - }) - }) - - describe('List Conversations', () => { - after(async () => Conversation.remove({})) - - it('can list Conversations when no one exists', async () => { - const conversations = await Conversation.find({}).exec() - expect(conversations).to.have.length(0) - }) - - it('can list 1 conversation', async () => { - await Promise.all([ - new Conversation(payload).save(), - new Conversation(payload).save(), - ]) - - const conversations = await Conversation.find({}).exec() - expect(conversations).to.have.length(2) - }) - }) - - describe('Update Conversation', () => { - after(async () => Conversation.remove({})) - - it('can update a conversation', async () => { - const newPayload = { isActive: false } - - const conversation = await new Conversation(payload).save() - const updatedConversation = await Conversation.findOneAndUpdate({ _id: conversation._id }, { $set: newPayload }, { new: true }) - - assert.equal(conversation.bot.toString(), updatedConversation.bot.toString()) - assert.equal(conversation.chatId, updatedConversation.chatId) - assert.equal(updatedConversation.isActive, false) - }) - }) - - describe('Delete Channel', () => { - after(async () => Conversation.remove({})) - - it('can remove conversation', async () => { - await new Conversation(payload).save() - const deletedConversations = await Conversation.remove({}) - - assert.equal(deletedConversations.result.n, 1) - }) - }) -}) diff --git a/test/models/Participant.model.tests.js b/test/models/Participant.model.tests.js deleted file mode 100644 index 4c36d4f..0000000 --- a/test/models/Participant.model.tests.js +++ /dev/null @@ -1,88 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -import Participant from '../../src/models/Participant.model' - -chai.use(chaiHttp) - -const assert = require('chai').assert - -const fakeParticipant = { - isBot: false, - senderId: '1234', -} - -function clearDB () { - for (const i in mongoose.connection.collections) { - if (mongoose.connection.collections.hasOwnProperty(i)) { - mongoose.connection.collections[i].remove() - } - } -} - -describe('Participant Model', () => { - describe('Create a participant', () => { - after(async () => clearDB()) - - it('can create bot when no one exists', async () => { - const participant = await new Participant(fakeParticipant).save() - - assert.equal(participant.isBot, fakeParticipant.isBot) - assert.equal(participant.senderId, fakeParticipant.senderId) - }) - }) - - describe('List participant', () => { - describe('with no participants', () => { - after(async () => clearDB()) - - it('can list participants', async () => { - const participants = await Participant.find({}).exec() - - chai.expect(participants.length).to.equal(0) - }) - }) - - describe('with participants', () => { - before(async () => Promise.all([ - new Participant(fakeParticipant).save(), - new Participant(fakeParticipant).save(), - ])) - after(async () => clearDB()) - - it('can index participants', async () => { - const participants = await Participant.find({}).exec() - - chai.expect(participants.length).to.equal(2) - }) - }) - }) - - describe('Update a participant:', () => { - describe('with participants', () => { - let participant = {} - before(async () => participant = await new Participant(fakeParticipant).save()) - after(async () => clearDB()) - - it('can update a participant', async () => { - const updatedParticipant = await Participant.findOneAndUpdate({ _id: participant._id }, { $set: { isBot: true } }, { new: true }) - assert.equal(updatedParticipant.isBot, true) - }) - }) - }) - - describe('Delete a participant:', () => { - describe('with participants', () => { - let participant = {} - before(async () => participant = await new Participant(fakeParticipant).save()) - after(async () => clearDB()) - - it('can remove a specific participant', async () => { - const deletedParticipants = await Participant.remove({ _id: participant._id }) - - assert.equal(deletedParticipants.result.n, 1) - }) - }) - }) -}) diff --git a/test/routes/.gitkeep b/test/routes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/routes/Bots.routes.tests.js b/test/routes/Bots.routes.tests.js deleted file mode 100644 index 3b0f9c3..0000000 --- a/test/routes/Bots.routes.tests.js +++ /dev/null @@ -1,32 +0,0 @@ -import chai from 'chai' -import fetchMethod from '../services/fetchMethod.service' - -import config from '../../config/test' - -import botController from '../../src/controllers/Bots.controller' - -describe("Routes", () => { - it("GET /bots/:bot_id", done => { - chai.expect(fetchMethod('GET', '/bots/:bot_id')).to.equal(botController.getBotById) - done() - }) - - it("GET /bots", done => { - chai.expect(fetchMethod('GET', '/bots')).to.equal(botController.getBots) - done() - }) - - it("POST /bots", done => { - chai.expect(fetchMethod('POST', '/bots')).to.equal(botController.createBot) - done() - }) - - it("PUT /bots/:bot_id", done => { - chai.expect(fetchMethod('PUT', '/bots/:bot_id')).to.equal(botController.updateBotById) - done() - }) - it("DELETE /bots/:bot_id", done => { - chai.expect(fetchMethod('DELETE', '/bots/:bot_id')).to.equal(botController.deleteBotById) - done() - }) -}) diff --git a/test/routes/Channels.routes.tests.js b/test/routes/Channels.routes.tests.js deleted file mode 100644 index 22ff44e..0000000 --- a/test/routes/Channels.routes.tests.js +++ /dev/null @@ -1,27 +0,0 @@ -import chai from 'chai' -import fetchMethod from '../services/fetchMethod.service' -import config from '../../config/test' -import channelController from '../../src/controllers/Channels.controller' - -describe("Routes", () => { - it("POST /bots/:bot_id/channels", done => { - chai.expect(fetchMethod('POST', '/bots/:bot_id/channels')).to.equal(channelController.createChannelByBotId) - done() - }) - it("GET /bots/:bot_id/channels", done => { - chai.expect(fetchMethod('GET', '/bots/:bot_id/channels')).to.equal(channelController.getChannelsByBotId) - done() - }) - it("GET /bots/:bot_id/channels/:channel_slug", done => { - chai.expect(fetchMethod('GET', '/bots/:bot_id/channels/:channel_slug')).to.equal(channelController.getChannelByBotId) - done() - }) - it("PUT /bots/:bot_id/channels/:channel_slug", done => { - chai.expect(fetchMethod('PUT', '/bots/:bot_id/channels/:channel_slug')).to.equal(channelController.updateChannelByBotId) - done() - }) - it("DELETE /bots/:bot_id/channels/:channel_slug", done => { - chai.expect(fetchMethod('DELETE', '/bots/:bot_id/channels/:channel_slug')).to.equal(channelController.deleteChannelByBotId) - done() - }) -}) diff --git a/test/routes/Conversations.routes.tests.js b/test/routes/Conversations.routes.tests.js deleted file mode 100644 index 9ce5676..0000000 --- a/test/routes/Conversations.routes.tests.js +++ /dev/null @@ -1,29 +0,0 @@ -import config from '../../config' -import ConversationController from '../../src/controllers/Conversations.controller' -import fetchMethod from '../services/fetchMethod.service' -const chai = require('chai') -const chaiHttp = require('chai-http') - -const expect = chai.expect -chai.use(chaiHttp) - -const Bot = require('../../src/models/Bot.model.js') - -describe('Conversation Routes', () => { - - it('Should call function getConversationsByBotId: GET /bots/:bot_id/conversations', (done) => { - chai.expect(fetchMethod('GET', '/bots/:bot_id/conversations')).to.equal(ConversationController.getConversationsByBotId) - done() - }) - - it('Should call function getConversationByBotId: GET /bots/:bot_id/conversations/conversation_id', (done) => { - //chai.expect(fetchMethod('GET', '/bots/:bot_id/conversations/conversation_id')).to.equal(ConversationController.getConversationByBotId) - chai.expect(fetchMethod('GET', '/bots/:bot_id/conversations/:conversation_id')).to.equal(ConversationController.getConversationByBotId) - done() - }) - - it('Should call function deleteConversationByBotId: DELETE /bots/:bot_id/conversations/:conversation_id', (done) => { - chai.expect(fetchMethod('DELETE', '/bots/:bot_id/conversations/:conversation_id')).to.equal(ConversationController.deleteConversationByBotId) - done() - }) -}) diff --git a/test/routes/Participants.routes.tests.js b/test/routes/Participants.routes.tests.js deleted file mode 100644 index 9f7b48e..0000000 --- a/test/routes/Participants.routes.tests.js +++ /dev/null @@ -1,16 +0,0 @@ -import chai from 'chai' -import fetchMethod from '../services/fetchMethod.service' - -import ParticipantsController from '../../src/controllers/Participants.controller' - -describe("Routes", () => { - it("GET /bots/:bot_id/participants", done => { - chai.expect(fetchMethod('GET', '/bots/:bot_id/participants')).to.equal(ParticipantsController.getParticipantsByBotId) - done() - }) - - it("GET /bots/:bot_id/participants/:participant_id", done => { - chai.expect(fetchMethod('GET', '/bots/:bot_id/participants/:participant_id')).to.equal(ParticipantsController.getParticipantByBotId) - done() - }) -}) diff --git a/test/routes/application.js b/test/routes/application.js new file mode 100644 index 0000000..2991fe1 --- /dev/null +++ b/test/routes/application.js @@ -0,0 +1,23 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import AppController from '../../src/controllers/application' +import AppRoutes from '../../src/routes/application' + +describe('App routes', () => { + + describe('GET /', () => { + it('should call AppController#index', async () => { + expect(fetchMethod(AppRoutes, 'GET', '/')).to.equal(AppController.index) + }) + }) + + describe('POST /', () => { + it('should call AppController#index', async () => { + expect(fetchMethod(AppRoutes, 'POST', '/')).to.equal(AppController.index) + }) + }) + +}) + diff --git a/test/routes/channels.js b/test/routes/channels.js new file mode 100644 index 0000000..26251a2 --- /dev/null +++ b/test/routes/channels.js @@ -0,0 +1,46 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import ChannelsController from '../../src/controllers/channels' +import ChannelsRoutes from '../../src/routes/channels' + +describe('Channels Routes', () => { + + describe('POST /channels', () => { + it('should call ChannelsController#create', async () => { + expect(fetchMethod(ChannelsRoutes, 'POST', '/connectors/:connectorId/channels')).to.equal(ChannelsController.create) + }) + }) + + describe('GET /channels', () => { + it('should call ChannelsController#index', async () => { + expect(fetchMethod(ChannelsRoutes, 'GET', '/connectors/:connectorId/channels')).to.equal(ChannelsController.index) + }) + }) + + describe('GET /channels/:channel_slug', () => { + it('should call ChannelsController#show', async () => { + expect( + fetchMethod(ChannelsRoutes, 'GET', '/connectors/:connectorId/channels/:channel_slug') + ).to.equal(ChannelsController.show) + }) + }) + + describe('PUT /channels/:channel_slug', () => { + it('should call ChannelsController#update', async () => { + expect( + fetchMethod(ChannelsRoutes, 'PUT', '/connectors/:connectorId/channels/:channel_slug') + ).to.equal(ChannelsController.update) + }) + }) + + describe('DELETE /channels/:channel_slug', () => { + it('should call ChannelsController#delete', async () => { + expect( + fetchMethod(ChannelsRoutes, 'DELETE', '/connectors/:connectorId/channels/:channel_slug') + ).to.equal(ChannelsController.delete) + }) + }) + +}) diff --git a/test/routes/connectors.js b/test/routes/connectors.js new file mode 100644 index 0000000..5d33de2 --- /dev/null +++ b/test/routes/connectors.js @@ -0,0 +1,38 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import Connectors from '../../src/controllers/connectors' +import ConnectorsRoutes from '../../src/routes/connectors' + +describe('Connectors Routes', () => { + + describe('GET /connectors/:bot_id', () => { + it('should call ConnectorsController#show', async () => { + expect(fetchMethod(ConnectorsRoutes, 'GET', '/connectors/:connectorId')).to.equal(Connectors.show) + }) + }) + + describe('POST /connectors', () => { + it('should call ConnectorsController#create', async () => { + expect(fetchMethod(ConnectorsRoutes, 'POST', '/connectors')).to.equal(Connectors.create) + }) + }) + + describe('PUT /connectors/:connectorId', () => { + it('should call ConnectorsController#update', async () => { + expect( + fetchMethod(ConnectorsRoutes, 'PUT', '/connectors/:connectorId') + ).to.equal(Connectors.update) + }) + }) + + describe('DELETE /connectors/:connectorId', () => { + it('should call ConnectorsController#delete', async () => { + expect( + fetchMethod(ConnectorsRoutes, 'DELETE', '/connectors/:connectorId') + ).to.equal(Connectors.delete) + }) + }) + +}) diff --git a/test/routes/conversations.js b/test/routes/conversations.js new file mode 100644 index 0000000..31b368a --- /dev/null +++ b/test/routes/conversations.js @@ -0,0 +1,42 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import ConversationsController from '../../src/controllers/conversations' +import ConversationsRoutes from '../../src/routes/conversations' + +describe('Conversations Routes', () => { + + describe('GET /conversations', () => { + it('should call ConversationsController#index', async () => { + expect( + fetchMethod(ConversationsRoutes, 'GET', '/connectors/:connectorId/conversations') + ).to.equal(ConversationsController.index) + }) + }) + + describe('GET /conversations/:conversation_id', () => { + it('should call ConversationsController#show', async () => { + expect( + fetchMethod(ConversationsRoutes, 'GET', '/connectors/:connectorId/conversations/:conversation_id') + ).to.equal(ConversationsController.show) + }) + }) + + describe('DELETE /conversations/:conversation_id', () => { + it('should call ConversationsController#delete', async () => { + expect( + fetchMethod(ConversationsRoutes, 'DELETE', '/connectors/:connectorId/conversations/:conversation_id') + ).to.equal(ConversationsController.delete) + }) + }) + + describe('POST /conversations/dump', () => { + it('should call ConversationsController#dumpDelete', async () => { + expect( + fetchMethod(ConversationsRoutes, 'POST', '/connectors/:connectorId/conversations/dump') + ).to.equal(ConversationsController.dumpDelete) + }) + }) + +}) diff --git a/test/routes/messages.js b/test/routes/messages.js new file mode 100644 index 0000000..748837d --- /dev/null +++ b/test/routes/messages.js @@ -0,0 +1,26 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import MessagesController from '../../src/controllers/messages' +import MessagesRoutes from '../../src/routes/messages' + +describe('Messages Routes', () => { + + describe('POST /connectors/:connectorId/conversations/:conversationId/messages', () => { + it('should call MessagesController#postMessage', async () => { + expect( + fetchMethod(MessagesRoutes, 'POST', '/connectors/:connectorId/conversations/:conversationId/messages') + ).to.equal(MessagesController.postMessage) + }) + }) + + describe('POST /connectors/:connectorId/messages', () => { + it('should call MessagesController#postMessages', async () => { + expect( + fetchMethod(MessagesRoutes, 'POST', '/connectors/:connectorId/messages') + ).to.equal(MessagesController.broadcastMessage) + }) + }) + +}) diff --git a/test/routes/participants.js b/test/routes/participants.js new file mode 100644 index 0000000..2c12400 --- /dev/null +++ b/test/routes/participants.js @@ -0,0 +1,22 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import ParticipantsController from '../../src/controllers/participants' +import ParticipantsRoutes from '../../src/routes/participants' + +describe('Participants Routes Testing', () => { + + describe('GET /connectors/:connectorId/participants', () => { + it('should call ParticipantsController#getParticipantsByConnectorId', async () => { + expect(fetchMethod(ParticipantsRoutes, 'GET', '/connectors/:connectorId/participants')).to.equal(ParticipantsController.index) + }) + }) + + describe('GET /connectors/:connectorId/participants/:participant_id', () => { + it('should call ParticipantsController#getParticipantByConnectorId', async () => { + expect(fetchMethod(ParticipantsRoutes, 'GET', '/connectors/:connectorId/participants/:participant_id')).to.equal(ParticipantsController.show) + }) + }) + +}) diff --git a/test/routes/persistent_menus.js b/test/routes/persistent_menus.js new file mode 100644 index 0000000..3570dc0 --- /dev/null +++ b/test/routes/persistent_menus.js @@ -0,0 +1,51 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import PersistentMenuController from '../../src/controllers/persistent_menus' +import PersistentMenusRoutes from '../../src/routes/persistent_menus' + +describe('Persistent Menu Routes', () => { + + describe('GET /connectors/:connectorId/persistentmenus', () => { + it('should call PersistentMenuController#index', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'GET', '/connectors/:connectorId/persistentmenus')).to.equal(PersistentMenuController.index) + }) + }) + + describe('GET /connectors/:connectorId/persistentmenus/:language', () => { + it('should call PersistentMenuController#show', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'GET', '/connectors/:connectorId/persistentmenus/:language')).to.equal(PersistentMenuController.show) + }) + }) + + describe('POST /connectors/:connectorId/persistentmenus', () => { + it('should call PersistentMenuController#create', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'POST', '/connectors/:connectorId/persistentmenus')).to.equal(PersistentMenuController.create) + }) + }) + + describe('POST /connectors/:connectorId/persistentmenus/setDefault', () => { + it('should call PersistentMenuController#setdefault', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'POST', '/connectors/:connectorId/persistentmenus/setdefault')).to.equal(PersistentMenuController.setDefault) + }) + }) + + describe('DELETE /connectors/:connectorId/persistentmenus', () => { + it('should call PersistentMenuController#deleteAll', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'DELETE', '/connectors/:connectorId/persistentmenus')).to.equal(PersistentMenuController.deleteAll) + }) + }) + + describe('DELETE /connectors/:connectorId/persistentmenus/:language', () => { + it('should call PersistentMenuController#delete', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'DELETE', '/connectors/:connectorId/persistentmenus/:language')).to.equal(PersistentMenuController.delete) + }) + }) + + describe('PUT /connectors/:connectorId/persistentmenus/:language', () => { + it('should call PersistentMenuController#update', async () => { + expect(fetchMethod(PersistentMenusRoutes, 'PUT', '/connectors/:connectorId/persistentmenus/:language')).to.equal(PersistentMenuController.update) + }) + }) +}) diff --git a/test/routes/webhooks.js b/test/routes/webhooks.js new file mode 100644 index 0000000..1deb475 --- /dev/null +++ b/test/routes/webhooks.js @@ -0,0 +1,46 @@ +import expect from 'expect.js' + +import { fetchMethod } from '../tools' + +import WebhookController from '../../src/controllers/webhooks' +import WebhooksRoutes from '../../src/routes/webhooks' + +describe('Webhooks Routes', () => { + + describe('POST /webhook/:channel_id', () => { + it('should call WebhooksController#handleMethodAction', async () => { + expect(fetchMethod(WebhooksRoutes, 'POST', '/webhook/:channel_id')).to.equal(WebhookController.handleMethodAction) + }) + }) + + describe('GET /webhook/:channel_id', () => { + it('should call WebhooksController#handleMethodAction', async () => { + expect(fetchMethod(WebhooksRoutes, 'GET', '/webhook/:channel_id')).to.equal(WebhookController.handleMethodAction) + }) + }) + + describe('POST /webhook/:channel_id/conversations', () => { + it('should call WebhooksController#createConversation', async () => { + expect(fetchMethod(WebhooksRoutes, 'POST', '/webhook/:channel_id/conversations')).to.equal(WebhookController.createConversation) + }) + }) + + describe('GET /webhook/:channel_id/conversations/:conversation_id/messages', () => { + it('should call WebhooksController#getMessages', async () => { + expect(fetchMethod(WebhooksRoutes, 'GET', '/webhook/:channel_id/conversations/:conversation_id/messages')).to.equal(WebhookController.getMessages) + }) + }) + + describe('GET /webhook/:channel_id/conversations/:conversation_id/poll', () => { + it('should call WebhooksController#poll', async () => { + expect(fetchMethod(WebhooksRoutes, 'GET', '/webhook/:channel_id/conversations/:conversation_id/poll')).to.equal(WebhookController.poll) + }) + }) + + describe('GET /webhook/:channel_id/preferences', () => { + it('should call WebhooksController#preferences', async () => { + expect(fetchMethod(WebhooksRoutes, 'GET', '/webhook/:channel_id/preferences')).to.equal(WebhookController.getPreferences) + }) + }) + +}) diff --git a/test/services/Facebook.service.tests.js b/test/services/Facebook.service.tests.js deleted file mode 100644 index 6877c00..0000000 --- a/test/services/Facebook.service.tests.js +++ /dev/null @@ -1,345 +0,0 @@ -import mongoose from 'mongoose' -import chai from 'chai' -import chaiHttp from 'chai-http' - -import FacebookService from '../../src/services/Messenger.service' -import { - invoke, - invokeSync, - getWebhookToken, -} from '../../src/utils/index.js' -chai.use(chaiHttp) -const expect = chai.expect -const should = chai.should() - -let payload -let res -const opts = { - senderId:'774961692607582', - chatId:'913902005381557'} - - - describe('FacebookService', () => { - - describe('Suscribe webhook', () => { - - it('should be Ok simple suscribe', async () => { - const req = { - query: {} - } - const channel = { - slug: '12345', - _id: '1231234' - } - - req.query['hub.mode'] = 'subscribe' - req.query['hub.verify_token'] = getWebhookToken(channel._id, channel.slug) - - const res = invokeSync('messenger', 'connectWebhook',[req, channel]) - expect(res).to.equal(true) - }) - - it('should be Ok bad token', async () => { - const req = { - query: {} - } - const channel = { - slug: '12345666', - _id: '12341234', - } - req.query['hub.mode'] = 'subscribe' - req.query['hub.verify_token'] = 'qwerqwerqwer' - - - const res = invokeSync('messenger', 'connectWebhook',[req, channel]) - expect(res).to.equal(false) - }) - }) - - xdescribe('Check Security', () => { - - it('should be Ok security check', async () => { - const req = { - headers: { - host: 'a02780b6.ngrok.io', - } - } - req.headers['X-Hub-Signature'] = '1234=1234s' - const channel = { - apiKey: '1234', - webhook: 'https://a02780b6.ngrok.io', - } - invoke('messenger', 'checkSecurity',[req, channel]).then(res => { - ; - }) - }) - - }) - - describe('Check all params are valide', () => { - - it('should be Ok all parameter', async () => { - const channel = { - apiKey: '1234', - webhook: 'https://a02780b6.ngrok.io', - token: 'qrqwerqwerjqkwerfqweiroqwoejqweiruqweprqwnje riqwerhqwpierhquwepriqnweorhuqweprhqnsdfjqpweiryqhsndfkqwuler', - } - const res = invokeSync('messenger', 'checkParamsValidity',[channel]) - expect(res).to.equal(true) - }) - - it('should not be OK messing token', async () => { - const channel = { - apiKey: '1234', - webhook: 'https://a02780b6.ngrok.io', - } - let err = null - try{ - const a = invokeSync('messenger', 'checkParamsValidity',[channel]) - } catch(e) { err = e } finally { expect(err).exist } - }) - - it('should not be OK messing webhhok', async () => { - const channel = { - apiKey: '1234', - token: '1234123412341234123412341234123412341234', - } - let err = null - try{ - const a = invokeSync('messenger', 'checkParamsValidity',[channel]) - } catch(e) { err = e } finally { expect(err).exist } - }) - - it('should not be OK messing apiKey', async () => { - const channel = { - webhook: '1234', - token: '1234123412341234123412341234123412341234', - } - let err = null - try{ - const a = invokeSync('messenger', 'checkParamsValidity',[channel]) - } catch(e) { err = e } finally { expect(err).exist } - }) - }) - - describe('Check extaOptions', () => { - - it('should be OK all option', async () => { - const req = { - body: { entry:[{messaging:[{recipient:{id:'12341234'},sender:{id:'sendeid'}}]}]} - } - const a = invokeSync('messenger', 'extractOptions',[req]) - expect(a.chatId).to.equal(req.body.entry[0].messaging[0].recipient.id) - expect(a.senderId).to.equal(req.body.entry[0].messaging[0].sender.id) - }) - }) - - describe('Parsing message', () => { - - it('should be Ok simple text', async () => { - payload = - { - object: 'page', - entry: [{ - id: '913902005381557', - time: 1476866470972, - messaging: [{ - sender: { id: '774961692607582' }, - recipient: { id: '913902005381557' }, - timestamp: 1476866467894, - message: { mid: 'mid.1476866467894:06f1095d94', seq: 2814, text: 'hello' } - }] - }] - } - const message = payload.entry[0].messaging[0] - const res = await invoke('messenger', 'parseChannelMessage',['conversation',payload, {id:'774961692607582',chatId:'913902005381557'}]) - expect(res[2].chatId).to.equal(message.recipient.id) - expect(res[2].id).to.equal(message.sender.id) - expect(res[1].attachment.text).to.equal(message.message.text) - }) - - it('should be Ok simple video', async () => { - - payload = - { - object: 'page', - entry: [{ - id: '913902005381557', - time: 1476866470972, - messaging: [{ - sender: { id: '774961692607582' }, - recipient: { id: '913902005381557' }, - timestamp: 1476866467894, - message: { attachment: [{ type: 'video', payload: { url: 'http://www.w3schools.com/css/paris.jpg' } }] - } - }] - }] - } - const message = payload.entry[0].messaging[0] - const res = await invoke('messenger', 'parseChannelMessage',['conversation',payload, {id:'774961692607582',chatId:'913902005381557'}]) - expect(res[2].id).to.equal(message.sender.id) - expect(res[1].attachment.text).to.equal(message.message.text) - }) - - it('should be Ok simple picture', async () => { - payload = - payload = - { - object: 'page', - entry: [{ - id: '913902005381557', - time: 1476866470972, - messaging: [{ - sender: { id: '774961692607582' }, - recipient: { id: '913902005381557' }, - timestamp: 1476866467894, - message: { - attachment: - [{ type: 'image', - payload: { url: 'http://www.w3schools.com/css/paris.jpg' } - }] - } - }] - }] - } - const message = payload.entry[0].messaging[0] - const res = await invoke('messenger', 'parseChannelMessage',['conversation',payload, {id:'774961692607582',chatId:'913902005381557'}]) - expect(res[2].chatId).to.equal(message.recipient.id) - expect(res[2].id).to.equal(message.sender.id) - expect(res[1].attachment.text).to.equal(message.message.text) - }) - - it('should be Ok First text', async () => { - payload = - { - object: 'page', - entry: [{ - id: '913902005381557', - time: 1476866470972, - messaging: [{ - sender: { id: '774961692607582' }, - recipient: { id: '913902005381557' }, - timestamp: 1476866467894, - postback: { mid: 'mid.1476866467894:06f1095d94', seq: 2814, text: 'hello' } - }] - }] - } - const message = payload.entry[0].messaging[0] - const res = await invoke('messenger', 'parseChannelMessage',['conversation',payload, {id:'774961692607582',chatId:'913902005381557'}]) - expect(res[2].chatId).to.equal(message.recipient.id) - expect(res[2].id).to.equal(message.sender.id) - expect(res[1].attachment.text).to.equal('start_conversation') - }) -}) - - -describe('Formatting message', () => { - it('should be Ok simple text', async () => { - payload = { - attachment: { - type: 'text', - content: 'Yo les loozers', - }, - } - - const res = await invoke('messenger', 'formatMessage',['conversation',payload, opts]) - expect(res.recipient.id).to.equal(opts.senderId) - expect(res.message.text).to.equal(payload.attachment.content) - }) - - it('should be Ok picture', async () => { - payload = { - attachment: { - type: 'picture', - content: 'http://www.w3schools.com/css/paris.jpg', - }, - } - - const res = await invoke('messenger', 'formatMessage',['conversation',payload, opts]) - expect(res.recipient.id).to.equal(opts.senderId) - expect(res.message.attachment.type).to.equal('image') - expect(res.message.attachment.payload.url).to.equal(payload.attachment.content) - }) - - it('should be Ok video', async () => { - payload = { - attachment: { - type: 'video', - content: 'https://www.youtube.com/watch?v=Ly7uj0JwgKg&list=FLa_5ITc5wcz3ZvbG1aFkWfg&index=51', - }, - } - const res = await invoke('messenger', 'formatMessage',['conversation',payload, opts]) - expect(res.recipient.id).to.equal(opts.senderId) - expect(res.message.attachment.type).to.equal(payload.attachment.type) - expect(res.message.attachment.payload.url).to.equal(payload.attachment.content) - }) - - it('should be Ok audio', async () => { - payload = { - attachment: { - type: 'audio', - content: 'https://www.youtube.com/watch?v=Ly7uj0JwgKg&list=FLa_5ITc5wcz3ZvbG1aFkWfg&index=51', - }, - } - const res = await invoke('messenger', 'formatMessage',['conversation',payload, opts]) - expect(res.recipient.id).to.equal(opts.senderId) - expect(res.message.attachment.type).to.equal(payload.attachment.type) - expect(res.message.attachment.payload.url).to.equal(payload.attachment.content) - }) - - it('should be Ok quickreplies', async () => { - payload = { - attachment: { - type: 'quickReplies', - content: { - title: 'i am the title', - template_type: 'button', - buttons: [{title:'1', value:'DEVELOPER_DEFINED_PAYLOAD_FOR_PICKING_RED', type: 'location'}, {title:'2',value:'DEVELOPER_DEFINED_PAYLOAD_FOR_PICKING_RED', type: 'text'}], - } - }, - } - const res = await invoke('messenger', 'formatMessage',['conversation',payload, opts]) - expect(res.recipient.id).to.equal(opts.senderId) - expect(res.message.quick_replies[0].content_type).to.equal(payload.attachment.content.buttons[0].type) - expect(res.message.quick_replies[0].title).to.equal(payload.attachment.content.buttons[0].title) - expect(res.message.quick_replies[0].payload).to.equal(payload.attachment.content.buttons[0].value) - expect(res.message.quick_replies[1].content_type).to.equal(payload.attachment.content.buttons[1].type) - expect(res.message.quick_replies[1].title).to.equal(payload.attachment.content.buttons[1].title) - expect(res.message.quick_replies[1].payload).to.equal(payload.attachment.content.buttons[1].value) - - }) - - it('should be Ok card', async () => { - - payload = { - attachment: { - type: 'card', - content: { - title: 'i am the title', - subtitle: "Soft white cotton t-shirt is back in style", - imageUrl: 'https://3.bp.blogspot.com/-W__wiaHUjwI/Vt3Grd8df0I/AAAAAAAAA78/7xqUNj8ujtY/s1600/image02.png', - itemUrl: 'https://www.google.fr/', - template_type: 'button', - buttons: [{title:'2',value:'https://www.google.fr/', type: 'web_url'},{type:'phone_number',title:'bruno',value:"+33675855738"}, {type:'element_share'}], - } - }, - } - const res = await invoke('messenger', 'formatMessage',['conversation',payload, opts]) - expect(res.recipient.id).to.equal(opts.senderId) - expect(res.message.attachment.type).to.equal('template') - expect(res.message.attachment.payload.template_type).to.equal('generic') - expect(res.message.attachment.payload.elements[0].image_url).to.equal(payload.attachment.content.imageUrl) - expect(res.message.attachment.payload.elements[0].item_url).to.equal(payload.attachment.content.itemUrl) - expect(res.message.attachment.payload.elements[0].subtitle).to.equal(payload.attachment.content.subtitle) - expect(res.message.attachment.payload.elements[0].buttons[0].title).to.equal(payload.attachment.content.buttons[0].title) - expect(res.message.attachment.payload.elements[0].buttons[0].url).to.equal(payload.attachment.content.buttons[0].value) - expect(res.message.attachment.payload.elements[0].buttons[0].type).to.equal(payload.attachment.content.buttons[0].type) - - expect(res.message.attachment.payload.elements[0].buttons[1].title).to.equal(payload.attachment.content.buttons[1].title) - expect(res.message.attachment.payload.elements[0].buttons[1].payload).to.equal(payload.attachment.content.buttons[1].value) - expect(res.message.attachment.payload.elements[0].buttons[1].type).to.equal(payload.attachment.content.buttons[1].type) - - expect(res.message.attachment.payload.elements[0].buttons[2].title).to.equal(payload.attachment.content.buttons[2].title) - }) -}) -}) diff --git a/test/services/Kik.service.tests.js b/test/services/Kik.service.tests.js deleted file mode 100644 index 54efb08..0000000 --- a/test/services/Kik.service.tests.js +++ /dev/null @@ -1,339 +0,0 @@ -import mongoose from 'mongoose' -import chai from 'chai' -import chaiHttp from 'chai-http' - -import KikService from '../../src/services/Kik.service' - -import { - invoke, - invokeSync, -} from '../../src/utils/index.js' - -chai.use(chaiHttp) -const expect = chai.expect -const should = chai.should() - -const opts = { - senderId:'774961692607582', - chatId:'913902005381557'} - - - let payload - let res - -describe('KikService', () => { - - describe('check checkSecurity', () => { - - it('should be Ok security check', async () => { - const req = { - headers: { - host: 'a02780b6.ngrok.io', - } - } - req.headers['x-kik-username'] = 'mybot' - const channel = { - userName: 'mybot', - webhook: 'https://a02780b6.ngrok.io', - } - const res = invokeSync('kik', 'checkSecurity',[req, channel]) - expect(res).to.equal(true) - }) - - it('should not be Ok security check', async () => { - const req = { - headers: { - host: 'a02780b6.ngrok.io', - } - } - req.headers['x-kik-username'] = 'qwer' - const channel = { - userName: 'mybot', - webhook: 'https://a02780b6.ngrok.io', - } - const res = invokeSync('kik', 'checkSecurity',[req, channel]) - expect(res).to.equal(false) - }) - - }) - - describe('Check all params are valide', () => { - it('should be Ok all parameter', async () => { - const channel = { - apiKey: '1234', - webhook: 'https://a02780b6.ngrok.io', - userName: 'user name', - } - invoke('kik', 'checkParamsValidity',[channel]).then(res => { - expect(res).to.equal(true) - }) - }) - it('should not be OK messing userName', async () => { - const channel = { - apiKey: '1234', - webhook: 'https://a02780b6.ngrok.io', - } - let err = null - try{ - const a = invokeSync('kik', 'checkParamsValidity',[channel]) - } catch(e) { err = e } finally { expect(err).exist } - }) - it('should not be OK messing webhhok', async () => { - const channel = { - apiKey: '1234', - userName: 'user name', - } - let err = null - try{ - const a = invokeSync('kik', 'checkParamsValidity',[channel]) - } catch(e) { err = e } finally { expect(err).exist } - }) - it('should not be OK messing apiKey', async () => { - const channel = { - webhook: '1234', - userName: 'user name', - } - let err = null - try{ - const a = invokeSync('kik', 'checkParamsValidity',[channel]) - } catch(e) { err = e } finally { expect(err).exist } - }) - }) - - describe('Check extaOptions', () => { - it('should be OK all option', async () => { - const req = { - body: {messages:[{chatId: '12341234', participants:['recast.ai']}]} - } - const a = invokeSync('kik', 'extractOptions',[req]) - expect(a.chatId).to.equal(req.body.messages[0].chatId) - expect(a.senderId).to.equal(req.body.messages[0].participants[0]) - }) - }) - - describe('Parsing message', () => { - it('should be Ok simple text', async ()=> { - payload = { - messages: [ - { - chatId: "0ee6d46753bfa6ac2f089149959363f3f59ae62b10cba89cc426490ce38ea92d", - id: "0115efde-e54b-43d5-873a-5fef7adc69fd", - type: "text", - from: "laura", - participants: ["laura"], - body: "omg r u real?", - timestamp: 1439576628405, - readReceiptRequested: true, - mention: null - } - ] - } - const res = await invoke('kik', 'parseChannelMessage',['conversation', payload, opts]) - expect(res[2].chatId).to.equal(opts.chatId) - expect(res[2].senderId).to.equal(opts.senderId) - expect(res[1].attachment.value).to.equal(payload.messages[0].body) - }) - - it('should be default when bad parameter', async ()=> { - payload = { - messages: [ - { - chatId: "0ee6d46753bfa6ac2f089149959363f3f59ae62b10cba89cc426490ce38ea92d", - id: "0115efde-e54b-43d5-873a-5fef7adc69fd", - type: "dblablalbl", - from: "laura", - participants: ["laura"], - body: "omg r u real?", - timestamp: 1439576628405, - readReceiptRequested: true, - mention: null - } - ] - } - const res = await invoke('kik', 'parseChannelMessage',['conversation', payload, opts]) - expect(res[2].chatId).to.equal(opts.chatId) - expect(res[2].senderId).to.equal(opts.senderId) - expect(res[1].attachment.value).to.equal('we don\'t handle this type') - }) - - it('should be Ok video', async() => { - payload = { - messages: [ - { - chatId: "b3be3bc15dbe59931666c06290abd944aaa769bb2ecaaf859bfb65678880afab", - type: "video", - from: "laura", - participants: ["laura"], - id: "6d8d060c-3ae4-46fc-bb18-6e7ba3182c0f", - timestamp: 1399303478832, - readReceiptRequested: true, - videoUrl: "http://example.kik.com/video.mp4", - mention: null - } - ] - } - const res = await invoke('kik', 'parseChannelMessage',['conversation', payload, opts]) - expect(res[2].chatId).to.equal(opts.chatId) - expect(res[2].senderId).to.equal(opts.senderId) - expect(res[1].attachment.value).to.equal(payload.messages[0].videoUrl) - }) - - it('should be Ok picture', async() => { - payload = { - messages: [ - { - chatId: "b3be3bc15dbe59931666c06290abd944aaa769bb2ecaaf859bfb65678880afab", - type: "picture", - from: "laura", - participants: ["laura"], - id: "6d8d060c-3ae4-46fc-bb18-6e7ba3182c0f", - picUrl: "http://example.kik.com/apicture.jpg", - timestamp: 1399303478832, - readReceiptRequested: true, - mention: null - } - ] - } - const res = await invoke('kik', 'parseChannelMessage',['conversation', payload, opts]) - expect(res[2].chatId).to.equal(opts.chatId) - expect(res[2].senderId).to.equal(opts.senderId) - expect(res[1].attachment.value).to.equal(payload.messages[0].picUrl) - }) - - it('should be Ok link', async() => { - payload = { - messages: [ - { - chatId: "b3be3bc15dbe59931666c06290abd944aaa769bb2ecaaf859bfb65678880afab", - type: "link", - from: "laura", - participants: ["laura"], - id: "6d8d060c-3ae4-46fc-bb18-6e7ba3182c0f", - timestamp: 83294238952, - url: "http://mywebpage.com", - noForward: true, - readReceiptRequested: true, - mention: null - } - ] - } - const res = await invoke('kik', 'parseChannelMessage',['conversation', payload, opts]) - expect(res[2].chatId).to.equal(opts.chatId) - expect(res[2].senderId).to.equal(opts.senderId) - expect(res[1].attachment.value).to.equal(payload.messages[0].url) - }) - - describe('Formatting message', () => { - it('should be Ok simple text', async() => { - payload = { - attachment: { - type: 'text', - content: 'Yo les loozers', - }, - } - const res = invokeSync('kik', 'formatMessage',['conversation', payload, opts]) - expect(res[0].chatId).to.equal(opts.chatId) - expect(res[0].to).to.equal(opts.senderId) - expect(res[0].type).to.equal(payload.attachment.type) - expect(res[0].body).to.equal(payload.attachment.content) - }) - - it('should be defaul whit wrong parmetter', async() => { - payload = { - attachment: { - type: 'qqwerreww', - content: 'Yo les loozers', - }, - } - const res = invokeSync('kik', 'formatMessage',['conversation', payload, opts]) - expect(res[0].chatId).to.equal(opts.chatId) - expect(res[0].to).to.equal(opts.senderId) - expect(res[0].type).to.equal('text') - expect(res[0].body).to.equal('wrong parameter') - }) - - - it('should be Ok picture', async () => { - payload = { - attachment: { - type: 'picture', - value: 'picurl', - } - } - const res = invokeSync('kik', 'formatMessage',['conversation', payload, opts]) - expect(res[0].chatId).to.equal(opts.chatId) - expect(res[0].to).to.equal(opts.senderId) - expect(res[0].type).to.equal(payload.attachment.type) - expect(res[0].body).to.equal(payload.attachment.content) - }) - - it('should be Ok video', async() => { - payload = { - attachment: { - type: 'video', - value: 'videourl', - }, - } - const res = invokeSync('kik', 'formatMessage',['conversation', payload, opts]) - expect(res[0].chatId).to.equal(opts.chatId) - expect(res[0].to).to.equal(opts.senderId) - expect(res[0].type).to.equal(payload.attachment.type) - expect(res[0].body).to.equal(payload.attachment.content) - }) - - it('should be Ok quickreplies', async() => { - payload = { - attachment: - { - type: 'quickReplies', - content: { - title: 'i am the title', - buttons: [{title:'1', value:'1'}, {title:'2',value:'2'},{title:'2',value:'2'},{title:'2',value:'2'},], - } - }, - } - const res = invokeSync('kik', 'formatMessage',['conversation', payload, opts]) - expect(res[0].chatId).to.equal(opts.chatId) - expect(res[0].to).to.equal(opts.senderId) - expect(res[0].type).to.equal('text') - expect(res[0].body).to.equal(payload.attachment.content.title) - expect(res[0].keyboards[0].type).to.equal('suggested') - expect(res[0].keyboards[0].responses[0].type).to.equal('text') - expect(res[0].keyboards[0].responses[0].body).to.equal(payload.attachment.content.buttons[0].value) - expect(res[0].keyboards[0].responses[1].type).to.equal('text') - expect(res[0].keyboards[0].responses[1].body).to.equal(payload.attachment.content.buttons[1].value) - expect(res[0].keyboards[0].responses[2].type).to.equal('text') - expect(res[0].keyboards[0].responses[2].body).to.equal(payload.attachment.content.buttons[2].value) - expect(res[0].keyboards[0].responses[3].type).to.equal('text') - expect(res[0].keyboards[0].responses[3].body).to.equal(payload.attachment.content.buttons[3].value) - }) - - it('should be Ok card', async() => { - payload = { - attachment: { - type: 'card', - content: { - title: 'hello', - imageUrl: 'imageUrl', - buttons: [{type: 'text', title:'hello'}], - }, - }, - } - - const res = invokeSync('kik', 'formatMessage',['conversation', payload, opts]) - expect(res[0].chatId).to.equal(opts.chatId) - expect(res[0].to).to.equal(opts.senderId) - expect(res[0].type).to.equal('text') - expect(res[0].body).to.equal(payload.attachment.content.title) - - expect(res[1].type).to.equal('picture') - expect(res[1].picUrl).to.equal(payload.attachment.content.imageUrl) - - expect(res[1].keyboards[0].type).to.equal('suggested') - expect(res[1].keyboards[0].type).to.equal('suggested') - expect(res[1].keyboards[0].responses[0].type).to.equal('text') - expect(res[1].keyboards[0].responses[0].body).to.equal(payload.attachment.content.buttons[0].title) - }) - }) - }) - }) diff --git a/test/services/Slack.service.tests.js b/test/services/Slack.service.tests.js deleted file mode 100644 index 9b1df12..0000000 --- a/test/services/Slack.service.tests.js +++ /dev/null @@ -1,403 +0,0 @@ -import chai from 'chai' -import sinon from 'sinon' -import { RtmClient } from '@slack/client' -import request from 'superagent' - -import Bot from '../../src/models/Bot.model' -import Channel from '../../src/models/Channel.model' -import Conversation from '../../src/models/Conversation.model' -import SlackService from '../../src/services/Slack.service' -import Logger from '../../src/utils/Logger' - -const expect = chai.expect - -let bot -let activeChannel -let inactiveChannel - -const url = 'http://localhost:8080' -const getChannelInfo = (slug, isActivated, bot) => { - return { - type: 'slack', - token: 'slack-token', - isActivated, - slug, - bot: bot._id, - } -} - -const textSlackMessage = { - text: 'Hello you!' -} - -const imageSlackMessage = { - file: { - mimetype: 'image/jpg', - url_private: 'some_image_url', - } -} - -const videoSlackMessage = { - file: { - mimetype: 'video/mp4', - url_private: 'some_video_url', - } -} - -const invalidSlackMessage = { - file: { - mimetype: 'application/json', - } -} - -const connectorTextMessage = { - attachment: { - type: 'text', - content: 'Hello you!' - } -} - -const connectorImageMessage = { - attachment: { - type: 'picture', - content: 'some_image_url' - } -} - -const connectorVideoMessage = { - attachment: { - type: 'video', - content: 'some_video_url' - } -} - -const connectorCardMessage = { - attachment: { - type: 'card', - content: { - title: 'A nice card', - imageUrl: 'some_image_url', - buttons: [{ - title: 'First button', - type: 'button', - value: 'First!', - }] - } - } -} - -const connectorQuickRepliesMessage = { - attachment: { - type: 'quickReplies', - content: { - title: 'A nice card', - buttons: [{ - title: 'First button', - value: 'First!', - }] - } - } -} - -const invalidConnectorMessage = { - attachment: { - type: 'invalid_type', - }, -} - - -describe('Slack service', () => { - before(async () => { - bot = await new Bot({ url, }).save() - await new Channel(getChannelInfo('slack-1', true, bot)).save() - activeChannel = await new Channel(getChannelInfo('slack-2', true, bot)).save() - inactiveChannel = await new Channel(getChannelInfo('slack-3', false, bot)).save() - await new Channel({ type: 'kik', isActivated: true, slug: 'kik-1', token: 'token-kik', bot: bot._id }).save() - }) - - after(async () => { - await Bot.remove({}) - await Channel.remove({}) - }) - - describe('checkParamsValidity', async () => { - it('should throw if not token is set', async () => { - const channel = await new Channel({ type: 'slack', slug: 'slug', isActivated: true , bot}).save() - let err = null - - try { - SlackService.checkParamsValidity(channel) - } catch (ex) { - err = ex - } - expect(err).not.to.equal(null) - - channel.token = 'token' - await channel.save() - err = null - let res = null - try { - res = SlackService.checkParamsValidity(channel) - } catch (ex) { - err = ex - } - expect(err).to.equal(null) - expect(res).to.equal(true) - await channel.remove() - }) - - describe('onLaunch', async () => { - it('should call onChannelCreate for each slack channels', async () => { - const stub = sinon.stub(SlackService, 'onChannelCreate', () => { true }) - await SlackService.onLaunch() - expect(SlackService.onChannelCreate.callCount).to.equal(3) - stub.restore() - }) - - it('should log and resolve on error', async () => { - sinon.spy(Logger, 'error') - const stub = sinon.stub(SlackService, 'onChannelCreate', () => { - throw new Error('error !') - }) - - await SlackService.onLaunch() - expect(Logger.error.calledOnce) - stub.restore() - Logger.error.restore() - }) - }) - - describe('onChannelCreate', async () => { - it('should return early is channel is not active', async () => { - sinon.spy(RtmClient, 'constructor') - SlackService.onChannelCreate(inactiveChannel) - expect(RtmClient.constructor.calledOnce).to.equal(false) - RtmClient.constructor.restore() - }) - - it('should start rtm connection if channel is active', async () => { - const startStub = sinon.stub(RtmClient.prototype, 'start', () => { true }) - const onStub = sinon.stub(RtmClient.prototype, 'on', () => { true }) - SlackService.onChannelCreate(activeChannel) - expect(RtmClient.constructor.calledOnce) - expect(RtmClient.prototype.on.calledOnce) - expect(RtmClient.prototype.start.calledOnce) - startStub.restore() - onStub.restore() - }) - - it('set rtm client in allRtm map', async () => { - const startStub = sinon.stub(RtmClient.prototype, 'start', () => { true }) - const onStub = sinon.stub(RtmClient.prototype, 'on', () => { true }) - SlackService.allRtm = new Map() - SlackService.onChannelCreate(activeChannel) - - expect(RtmClient.constructor.calledOnce) - expect(RtmClient.prototype.on.calledOnce) - expect(RtmClient.prototype.start.calledOnce) - - expect(SlackService.allRtm.get(activeChannel._id.toString())).to.exist - - startStub.restore() - onStub.restore() - }) - }) - - describe('onChannelDelete', async () => { - it('should close rtm connection and remove channel from allRtm', async () => { - const startStub = sinon.stub(RtmClient.prototype, 'start', () => { true }) - const onStub = sinon.stub(RtmClient.prototype, 'on', () => { true }) - const disconnectStub = sinon.stub(RtmClient.prototype, 'disconnect', () => { true }) - SlackService.allRtm = new Map() - SlackService.onChannelCreate(activeChannel) - - expect(RtmClient.constructor.calledOnce) - expect(RtmClient.prototype.on.calledOnce) - expect(RtmClient.prototype.start.calledOnce) - expect(SlackService.allRtm.get(activeChannel._id.toString())).to.exist - - SlackService.onChannelDelete(activeChannel) - expect(RtmClient.prototype.disconnect.calledOnce) - expect(SlackService.allRtm.get(activeChannel._id.toString())).not.to.exist - - startStub.restore() - onStub.restore() - disconnectStub.restore() - }) - - it('should do nothing is rtm connection is not active', async () => { - const disconnectStub = sinon.stub(RtmClient.prototype, 'disconnect', () => { true }) - SlackService.allRtm = new Map() - - SlackService.onChannelDelete(activeChannel) - expect(RtmClient.prototype.disconnect.calledOnce).to.equal(false) - - disconnectStub.restore() - }) - }) - - describe('onChannelUpdate', async () => { - it('should call onDelete and onCreate', async () => { - const deleteStub = sinon.stub(SlackService, 'onChannelDelete', () => { true }) - const createStub = sinon.stub(SlackService, 'onChannelCreate', () => { Promise.resolve(true) }) - SlackService.onChannelUpdate(activeChannel) - expect(deleteStub.calledOnce) - deleteStub.restore() - expect(createStub.calledOnce) - createStub.restore() - }) - }) - - describe('parseChannelMessage', async () => { - it('should parse text message correctly', async () => { - const conv = {}, opts = {} // This methd doesnt use conversation nor opts - - const [c, parsedMessage, opt] = await SlackService.parseChannelMessage(conv, textSlackMessage, opts) - expect(parsedMessage.channelType).to.equal('slack') - expect(parsedMessage.attachment.type).to.equal('text') - expect(parsedMessage.attachment.content).to.equal('Hello you!') - }) - - it('should parse image message correctly', async () => { - const conv = {}, opts = {} // This methd doesnt use conversation nor opts - - const [c, parsedMessage, opt] = await SlackService.parseChannelMessage(conv, imageSlackMessage, opts) - expect(parsedMessage.channelType).to.equal('slack') - expect(parsedMessage.attachment.type).to.equal('picture') - expect(parsedMessage.attachment.content).to.equal('some_image_url') - }) - - it('should parse video message correctly', async () => { - const conv = {}, opts = {} // This methd doesnt use conversation nor opts - - const [c, parsedMessage, opt] = await SlackService.parseChannelMessage(conv, videoSlackMessage, opts) - expect(parsedMessage.channelType).to.equal('slack') - expect(parsedMessage.attachment.type).to.equal('picture') - expect(parsedMessage.attachment.content).to.equal('some_video_url') - }) - - it('should throw error on other mimetype', async () => { - const conv = {}, opts = {} // This methd doesnt use conversation nor opts - - let err = null - try { - const [c, parsedMessage, opt] = await SlackService.parseChannelMessage(conv, invalidSlackMessage, opts) - } catch (ex) { - err = ex - } - expect(err).to.exist - expect(err.message).to.equal('Sorry but we don\'t handle such type of file') - }) - }) - - describe('parseMessage', async () => { - it('should format text messages correctly', () => { - const formattedMessage = SlackService.formatMessage({}, connectorTextMessage) - expect(formattedMessage.text).to.equal(connectorTextMessage.attachment.content) - }) - - it('should format image messages correctly', () => { - const formattedMessage = SlackService.formatMessage({}, connectorImageMessage) - expect(formattedMessage.text).to.equal(connectorImageMessage.attachment.content) - }) - - it('should format video messages correctly', () => { - const formattedMessage = SlackService.formatMessage({}, connectorVideoMessage) - expect(formattedMessage.text).to.equal(connectorVideoMessage.attachment.content) - }) - - it('should format card messages correctly', () => { - const formattedMessage = SlackService.formatMessage({}, connectorCardMessage) - const attach = formattedMessage.attachments[0] - expect(attach.fallback).to.equal('Sorry but I can\'t display buttons') - expect(attach.attachment_type).to.equal('default') - - expect(attach.actions[0].name).to.equal('First button') - expect(attach.actions[0].text).to.equal('First button') - expect(attach.actions[0].value).to.equal('First!') - expect(attach.actions[0].type).to.equal('button') - }) - - it('should format quickReplies messages correctly', () => { - const formattedMessage = SlackService.formatMessage({}, connectorQuickRepliesMessage) - const attach = formattedMessage.attachments[0] - expect(attach.fallback).to.equal('Sorry but I can\'t display buttons') - expect(attach.attachment_type).to.equal('default') - - expect(attach.actions[0].name).to.equal('First button') - expect(attach.actions[0].text).to.equal('First button') - expect(attach.actions[0].value).to.equal('First!') - expect(attach.actions[0].type).to.equal('button') - }) - - it('should throw with invalid message type', () => { - let err = null - - try { - SlackService.formatMessage({}, invalidConnectorMessage) - } catch (ex) { - err = ex - } - - expect(err).to.exist - expect(err.message).to.equal('Invalid message type') - }) - }) - - describe('sendMessage', async () => { - it('should make request to slack', async () => { - const formattedMessage = SlackService.formatMessage({}, connectorCardMessage) - const channel = activeChannel - const convers = await new Conversation({ chatId: 'chatId', channel: channel._id, bot: bot }).save() - convers.channel = channel - await convers.save() - - const expectedParams = `https://slack.com/api/chat.postMessage?token=${channel.token}&channel=${convers.chatId}&as_user=true&text=${formattedMessage.text}&attachments=${JSON.stringify(formattedMessage.attachments)}` - - const requestStub = sinon.stub(request, 'post', (url) => { - expect(url).to.equal(expectedParams) - // Fake superagent end method - return { end: (cb) => cb(null) } - }) - - const res = await SlackService.sendMessage(convers, formattedMessage) - - expect(res).to.equal('Message sent') - expect(requestStub.calledWith(expectedParams)) - - requestStub.restore() - }) - - it('should log and throw on error', async () => { - const formattedMessage = SlackService.formatMessage({}, connectorCardMessage) - const channel = activeChannel - const convers = await new Conversation({ chatId: 'chatId', channel: channel._id, bot: bot }).save() - convers.channel = channel - await convers.save() - - const expectedParams = `https://slack.com/api/chat.postMessage?token=${channel.token}&channel=${convers.chatId}&as_user=true&text=${formattedMessage.text}&attachments=${JSON.stringify(formattedMessage.attachments)}` - - const loggerStub = sinon.stub(Logger, 'error') - const requestStub = sinon.stub(request, 'post', (url) => { - expect(url).to.equal(expectedParams) - // Fake superagent end method - return { end: (cb) => cb(new Error('Fake error')) } - }) - - let err = null - try { - const res = await SlackService.sendMessage(convers, formattedMessage) - } catch (ex) { - err = ex - } - - expect(err).exist - expect(requestStub.calledWith(expectedParams)) - expect(loggerStub.calledOnce) - - requestStub.restore() - loggerStub.restore() - }) - }) - }) -}) diff --git a/test/services/fetchMethod.service.js b/test/services/fetchMethod.service.js deleted file mode 100644 index 6f614d9..0000000 --- a/test/services/fetchMethod.service.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = (method, routePath) => { - const router = global.app._router.stack.filter(middleware => middleware.name === 'router') - - if (!router || router.length < 1) { - return null - } - - const routes = router[0].handle.stack.filter(route => route.route.methods[method.toLowerCase()] && routePath.match(route.regexp)) - - if (!routes || routes.length < 1) { - return null - } - - const stack = routes[0].route.stack - - if (!stack || stack.length < 1) { - return null - } - - // We take the last function, because the previous ones are middleware - return stack[stack.length - 1].handle -} diff --git a/test/tools/index.js b/test/tools/index.js new file mode 100644 index 0000000..0ae51fe --- /dev/null +++ b/test/tools/index.js @@ -0,0 +1,14 @@ +export function fetchMethod (routesArray, method, path) { + for (let route of routesArray) { + // If methods match + // and route.path is a string and match + // or route.path is an array and match + if (route.method.toLowerCase() === method.toLowerCase() && ((typeof route.path === 'string' && route.path === path) || (typeof route.path === 'object' && route.path.indexOf(path) >= 0))) { + return route.handler + } + } + + return null +} + +export { setupChannelIntegrationTests } from './integration_setup' diff --git a/test/tools/integration_setup.js b/test/tools/integration_setup.js new file mode 100644 index 0000000..0b6df4a --- /dev/null +++ b/test/tools/integration_setup.js @@ -0,0 +1,68 @@ +import { Channel, Connector } from '../../src/models' +import config from '../../config/test' +import nock from 'nock' +import superagentPromise from 'superagent-promise' +import superagent from 'superagent' +import _ from 'lodash' + +const agent = superagentPromise(superagent, Promise) +const connectorUrl = config.skillsBuilderUrl +async function newConnector (opts = {}) { + const connector = new Connector({ + url: opts.url || connectorUrl, + isActive: opts.isActive || true, + }) + return connector.save() +} + +export function setupChannelIntegrationTests (cleanupDB = true) { + + process.env.ROUTETEST = `http://localhost:${config.server.port}` + + afterEach(async () => { + if (cleanupDB) { + await Connector.remove() + await Channel.remove() + } + }) + + async function sendMessageToWebhook ( + channel, message, additionalHeaders = {}, connectorResponse + ) { + nock(connectorUrl) + .post('') + .reply(200, connectorResponse || { + messages: JSON.stringify([{ type: 'text', content: 'my message' }]), + }) + const baseHeaders = { + Accept: '*/*', + } + const headers = _.extend(baseHeaders, additionalHeaders) + try { + return await agent.post(channel.webhook).set(headers).send(message) + } catch (e) { + nock.cleanAll() + throw e + } + + } + + return { + sendMessageToWebhook, + createChannel: async (parameters) => { + const connector = await newConnector() + return agent.post(`${process.env.ROUTETEST}/v1/connectors/${connector._id}/channels`) + .send(parameters) + }, + deleteChannel: async (channel, headers = {}) => { + return agent.del(`${process.env.ROUTETEST}/v1/connectors/${channel.connector}/channels/${channel.slug}`) + .set(headers) + .send() + }, + updateChannel: async (channel, payload, headers = {}) => { + return agent.put(`${process.env.ROUTETEST}/v1/connectors/${channel.connector}/channels/${channel.slug}`) + .set(headers) + .send(payload) + }, + } +} diff --git a/test/util/message_queue.js b/test/util/message_queue.js new file mode 100644 index 0000000..2a25acd --- /dev/null +++ b/test/util/message_queue.js @@ -0,0 +1,96 @@ +import chai from 'chai' +import defaultMessageQueue from '../../src/utils/message_queue' +import { MessageQueue } from '../../src/utils/message_queue' + +const should = chai.should() +const expect = chai.expect + +describe('Message Queue', () => { + + let queue + beforeEach(() => { + queue = new MessageQueue() + }) + + const watcher = () => true + const watcher2 = () => false + const conversationId = 'abc' + + it('should initialize all necessary fields', () => { + should.exist(queue.queue) + expect(queue.pollWatchers).to.eql({}) + }) + + describe('setWatcher', () => { + it('should add a new watcher if none exists', () => { + const watcherId = '123' + queue.setWatcher(conversationId, watcherId, watcher) + expect(queue.pollWatchers).to.have.property(conversationId) + expect(queue.pollWatchers[conversationId]).to.have.property(watcherId) + expect(queue.pollWatchers[conversationId][watcherId].handler).to.equal(watcher) + }) + + it('should add a second watcher if one already exists', () => { + const firstWatcherId = '123' + const secondWatcherId = '456' + queue.setWatcher(conversationId, firstWatcherId, watcher) + queue.setWatcher(conversationId, secondWatcherId, watcher2) + expect(queue.pollWatchers[conversationId]).to.have.property(firstWatcherId) + expect(queue.pollWatchers[conversationId]).to.have.property(secondWatcherId) + }) + + it('should replace an existing watcher if IDs match', () => { + const watcherId = '123' + const secondWatcher = () => 'something else' + queue.setWatcher(conversationId, watcherId, watcher) + queue.setWatcher(conversationId, watcherId, secondWatcher) + expect(queue.pollWatchers[conversationId][watcherId].handler).to.equal(secondWatcher) + }) + }) + + describe('removeWatcher', () => { + it('should remove an existing watcher', () => { + const watcherId = '123' + queue.setWatcher(conversationId, watcherId, watcher) + queue.removeWatcher(conversationId, watcherId) + expect(queue.pollWatchers).not.to.have.property(conversationId) + }) + + it('should remove keep an existing watcher with same conversationId', () => { + const firstWatcherId = '123' + const secondWatcherId = '456' + queue.setWatcher(conversationId, firstWatcherId, watcher) + queue.setWatcher(conversationId, secondWatcherId, watcher2) + queue.removeWatcher(conversationId, firstWatcherId, watcher2) + expect(queue.pollWatchers).to.have.property(conversationId) + expect(queue.pollWatchers[conversationId]).not.to.have.property(firstWatcherId) + expect(queue.pollWatchers[conversationId]).to.have.property(secondWatcherId) + }) + + it('should not have any side effects for unknown IDs', () => { + const watcherId = '123' + queue.setWatcher(conversationId, watcherId, watcher) + queue.removeWatcher(conversationId, 'unknown') + queue.removeWatcher('unknown', 'unknown') + queue.removeWatcher('unknown', watcherId) + expect(queue.pollWatchers).to.have.property(conversationId) + expect(queue.pollWatchers[conversationId]).to.have.property(watcherId) + expect(queue.pollWatchers[conversationId][watcherId].handler).to.equal(watcher) + }) + + }) + + describe('default export', () => { + it('should ba an instance of MessageQueue', () => { + expect(defaultMessageQueue).to.be.an.instanceof(MessageQueue) + }) + + it('should be subscribed to all necessary events', () => { + expect(defaultMessageQueue).to.be.an.instanceof(MessageQueue) + expect(defaultMessageQueue.getQueue()._events).to.have.property('error') + expect(defaultMessageQueue.getQueue()._events).to.have.property('job enqueue') + }) + + }) + +}) diff --git a/test/util/start_application.js b/test/util/start_application.js new file mode 100644 index 0000000..853b817 --- /dev/null +++ b/test/util/start_application.js @@ -0,0 +1,13 @@ +import nock from 'nock' +import { startApplication } from '../../src/app' + +const config = require('../../config/test') +process.env.ROUTETEST = `http://localhost:${config.server.port}` + +before(async () => { + // nock all external request + nock.disableNetConnect() + nock.enableNetConnect(/(localhost)/) + + await startApplication() +}) diff --git a/test/validators/Bots.validators.tests.js b/test/validators/Bots.validators.tests.js deleted file mode 100644 index fd4ed85..0000000 --- a/test/validators/Bots.validators.tests.js +++ /dev/null @@ -1,95 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -import Bot from '../../src/models/Bot.model' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -const baseUrl = 'http://localhost:8080' - -describe('Bot validator', () => { - describe('createBot', () => { - it('should be a 400 with an invalid url', async () => { - try { - await chai.request(baseUrl).post('/bots').send() - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter url is invalid') - } - }) - }) - - describe('getBotById', () => { - it('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).get('/bots/12345').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter bot_id is invalid') - } - }) - }) - - describe('updateBotById', () => { - let bot = {} - before(async () => bot = await new Bot({ url: 'https://test.com' })) - - it('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).put('/bots/12345').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter bot_id is invalid') - } - }) - - it('should be a 400 with an invalid url', async () => { - try { - const res = await chai.request(baseUrl).put(`/bots/${bot._id}`).send({ url: 'invalid' }) - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter url is invalid') - } - }) - }) - - describe('deleteBotById', () => { - it('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).del('/bots/12345').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter bot_id is invalid') - } - }) - }) -}) - diff --git a/test/validators/Channels.validators.tests.js b/test/validators/Channels.validators.tests.js deleted file mode 100644 index c440840..0000000 --- a/test/validators/Channels.validators.tests.js +++ /dev/null @@ -1,174 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -import Bot from '../../src/models/Bot.model' -import Channel from '../../src/models/Channel.model' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -const url = 'https://test.com' -const baseUrl = 'http://localhost:8080' -const payload = { - type: 'slack', - isActivated: true, - slug: 'test', -} - -describe('Channel validator', () => { - describe('createChannelByBotId', () => { - let bot = {} - before(async () => bot = await new Bot({ url })) - after(async () => await Bot.remove({})) - - it ('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).post('/bots/1234/channels').send() - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter bot_id is invalid') - } - }) - - it('should be a 400 with a missing type', async () => { - const payload = { isActivated: true, slug: 'slug-test' } - try { - await chai.request(baseUrl).post(`/bots/${bot._id}/channels`).send(payload) - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter type is missing') - } - }) - - it ('should be a 400 with an invalid type', async () => { - const payload = { type: 'invalid', isActivated: true, slug: 'slug-test' } - try { - await chai.request(baseUrl).post(`/bots/${bot._id}/channels`).send(payload) - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter type is invalid') - } - }) - - it ('should be a 400 with a missing isActivated', async () => { - const payload = { type: 'slack', slug: 'slug-test' } - try { - await chai.request(baseUrl).post(`/bots/${bot._id}/channels`).send(payload) - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter isActivated is missing') - } - }) - - it ('should be a 400 with a missing slug', async () => { - const payload = { type: 'slack', isActivated: true } - try { - await chai.request(baseUrl).post(`/bots/${bot._id}/channels`).send(payload) - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter slug is missing') - } - }) - - }) - - describe('getChannelsByBotId', () => { - it ('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).get('/bots/1234/channels').send() - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter bot_id is invalid') - } - }) - }) - - describe('getChannelByBotId', () => { - let bot = {} - before(async () => bot = await new Bot({ url }).save()) - after(async () => Bot.remove({})) - - it ('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).get('/bots/1234/channels/1234').send() - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter bot_id is invalid') - } - }) - }) - - describe('updateChannelByBotId', () => { - let bot = {} - let channel = {} - before(async () => { - bot = await new Bot({ url }).save() - channel = await new Channel({ ...payload, bot: bot._id }).save() - }) - after(async () => Promise.all([Bot.remove({}), Channel.remove({})])) - - it ('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).put(`/bots/1234/channels/${channel._slug}`).send() - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter bot_id is invalid') - } - }) - - it ('should be a 409 with an invalid bot_id', async () => { - try { - await new Channel({ ...payload, slug: 'test1', bot: bot._id }).save() - await chai.request(baseUrl).put(`/bots/${bot._id}/channels/${channel.slug}`).send({ slug: 'test1' }) - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 409) - assert.equal(res.body.message, 'Channel slug already exists') - } - }) - }) - - describe('deleteChannelBotById', () => { - it ('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).del(`/bots/1234/channels/test`).send() - should.fail() - } catch (err) { - const res = err.response - - assert.equal(res.status, 400) - assert.equal(res.body.message, 'Parameter bot_id is invalid') - } - }) - }) -}) diff --git a/test/validators/Participants.validators.tests.js b/test/validators/Participants.validators.tests.js deleted file mode 100644 index 869e0db..0000000 --- a/test/validators/Participants.validators.tests.js +++ /dev/null @@ -1,68 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -import Bot from '../../src/models/Bot.model' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -const url = 'https://bonjour.com' -const baseUrl = 'http://localhost:8080' - -describe('Participant validator', () => { - describe('getParticipantsByBotId', () => { - it('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).get('/bots/12345/participants').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter bot_id is invalid') - } - }) - }) - - describe('getParticipantByBotId', () => { - let bot = {} - before(async () => bot = await new Bot({ url }).save()) - after(async () => Bot.remove({})) - - it('should be a 400 with an invalid bot_id', async () => { - try { - await chai.request(baseUrl).get('/bots/1234/participants/test').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter bot_id is invalid') - } - }) - - it('should be a 400 with an invalid participant_id', async () => { - try { - await chai.request(baseUrl).get(`/bots/${bot._id}/participants/test`).send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter participant_id is invalid') - } - }) - }) -}) - - diff --git a/test/validators/Webhooks.validators.tests.js b/test/validators/Webhooks.validators.tests.js deleted file mode 100644 index 8b5019a..0000000 --- a/test/validators/Webhooks.validators.tests.js +++ /dev/null @@ -1,47 +0,0 @@ -import chai from 'chai' -import chaiHttp from 'chai-http' -import mongoose from 'mongoose' - -const assert = require('chai').assert -const expect = chai.expect -const should = chai.should() - -chai.use(chaiHttp) - -const baseUrl = 'http://localhost:8080' - -describe('Webhook validator', () => { - describe('forwardMessage', () => { - it('should be a 400 with an invalid channel_id', async () => { - try { - await chai.request(baseUrl).post('/webhook/12345').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter channel_id is invalid') - } - }) - }) - - describe('subscribeWebhook', () => { - it('should be a 400 with an invalid channel_id', async () => { - try { - await chai.request(baseUrl).get('/webhook/12345').send() - should.fail() - } catch (err) { - const res = err.response - const { message, results } = res.body - - assert.equal(res.status, 400) - assert.equal(results, null) - assert.equal(message, 'Parameter channel_id is invalid') - } - }) - }) -}) - - diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index 26afc1e..0000000 --- a/yarn.lock +++ /dev/null @@ -1,3855 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@slack/client@^3.6.0": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@slack/client/-/client-3.6.1.tgz#4af22459b8019b8a8d7620dd20a9d4c152003f0b" - dependencies: - async "^1.5.0" - bluebird "^3.3.3" - eventemitter3 "^1.1.1" - https-proxy-agent "^1.0.0" - inherits "^2.0.1" - lodash "^4.13.1" - request "^2.64.0" - retry "^0.9.0" - url-join "0.0.1" - winston "^2.1.1" - ws "^1.0.1" - -abbrev@1, abbrev@1.0.x: - version "1.0.9" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" - -accepts@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" - dependencies: - mime-types "~2.1.11" - negotiator "0.6.1" - -acorn-jsx@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b" - dependencies: - acorn "^3.0.4" - -acorn@^3.0.4: - version "3.3.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" - -acorn@^4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.3.tgz#1a3e850b428e73ba6b09d1cc527f5aaad4d03ef1" - -agent-base@2: - version "2.0.1" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-2.0.1.tgz#bd8f9e86a8eb221fffa07bd14befd55df142815e" - dependencies: - extend "~3.0.0" - semver "~5.0.1" - -ajv-keywords@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.1.1.tgz#02550bc605a3e576041565628af972e06c549d50" - -ajv@^4.7.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.9.0.tgz#5a358085747b134eb567d6d15e015f1d7802f45c" - dependencies: - co "^4.6.0" - json-stable-stringify "^1.0.1" - -align-text@^0.1.1, align-text@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" - dependencies: - kind-of "^3.0.2" - longest "^1.0.1" - repeat-string "^1.5.2" - -amdefine@>=0.0.4: - version "1.0.1" - resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" - -ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - -ansi-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.0.0.tgz#c5061b6e0ef8a81775e50f5d66151bf6bf371107" - -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - -ansi-styles@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178" - -anymatch@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507" - dependencies: - arrify "^1.0.0" - micromatch "^2.1.5" - -apidoc-core@~0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/apidoc-core/-/apidoc-core-0.7.1.tgz#e686dad66281e040e16635dd3f7cbab1c4bac724" - dependencies: - glob "^7.0.3" - iconv-lite "^0.4.13" - lodash "~4.11.1" - semver "~5.1.0" - wrench "~1.5.9" - -apidoc@^0.16.1: - version "0.16.1" - resolved "https://registry.yarnpkg.com/apidoc/-/apidoc-0.16.1.tgz#26a1cde4f2c4910936ad57adf41dbc83c246e5e9" - dependencies: - apidoc-core "~0.7.1" - fs-extra "~0.28.0" - lodash "~4.11.1" - markdown-it "^6.0.1" - nomnom "~1.8.1" - winston "~2.2.0" - -aproba@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.0.4.tgz#2713680775e7614c8ba186c065d4e2e52d1072c0" - -are-we-there-yet@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz#80e470e95a084794fe1899262c5667c6e88de1b3" - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.0 || ^1.1.13" - -argparse@^1.0.7: - version "1.0.9" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86" - dependencies: - sprintf-js "~1.0.2" - -arr-diff@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" - dependencies: - arr-flatten "^1.0.1" - -arr-flatten@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.1.tgz#e5ffe54d45e19f32f216e91eb99c8ce892bb604b" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - -array-union@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" - dependencies: - array-uniq "^1.0.1" - -array-uniq@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" - -array-unique@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" - -arrify@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - -asap@~2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" - -asn1@~0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" - -assert-plus@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" - -assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - -assertion-error@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" - -async-each@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" - -async@1.x, async@^1.4.0, async@^1.5.0, async@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" - -async@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/async/-/async-2.1.2.tgz#612a4ab45ef42a70cde806bad86ee6db047e8385" - dependencies: - lodash "^4.14.0" - -async@~0.2.6: - version "0.2.10" - resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - -async@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async/-/async-1.0.0.tgz#f8fc04ca3a13784ade9e1641af98578cfbd647a9" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - -aws-sign2@~0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" - -aws4@^1.2.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.5.0.tgz#0a29ffb79c31c9e712eeb087e8e7a64b4a56d755" - -babel-cli@^6.16.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-cli/-/babel-cli-6.18.0.tgz#92117f341add9dead90f6fa7d0a97c0cc08ec186" - dependencies: - babel-core "^6.18.0" - babel-polyfill "^6.16.0" - babel-register "^6.18.0" - babel-runtime "^6.9.0" - commander "^2.8.1" - convert-source-map "^1.1.0" - fs-readdir-recursive "^1.0.0" - glob "^5.0.5" - lodash "^4.2.0" - output-file-sync "^1.1.0" - path-is-absolute "^1.0.0" - slash "^1.0.0" - source-map "^0.5.0" - v8flags "^2.0.10" - optionalDependencies: - chokidar "^1.0.0" - -babel-code-frame@^6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.16.0.tgz#f90e60da0862909d3ce098733b5d3987c97cb8de" - dependencies: - chalk "^1.1.0" - esutils "^2.0.2" - js-tokens "^2.0.0" - -babel-core@^6.18.0: - version "6.18.2" - resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.18.2.tgz#d8bb14dd6986fa4f3566a26ceda3964fa0e04e5b" - dependencies: - babel-code-frame "^6.16.0" - babel-generator "^6.18.0" - babel-helpers "^6.16.0" - babel-messages "^6.8.0" - babel-register "^6.18.0" - babel-runtime "^6.9.1" - babel-template "^6.16.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - babylon "^6.11.0" - convert-source-map "^1.1.0" - debug "^2.1.1" - json5 "^0.5.0" - lodash "^4.2.0" - minimatch "^3.0.2" - path-is-absolute "^1.0.0" - private "^0.1.6" - slash "^1.0.0" - source-map "^0.5.0" - -babel-eslint@^7.0.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-7.1.1.tgz#8a6a884f085aa7060af69cfc77341c2f99370fb2" - dependencies: - babel-code-frame "^6.16.0" - babel-traverse "^6.15.0" - babel-types "^6.15.0" - babylon "^6.13.0" - lodash.pickby "^4.6.0" - -babel-generator@^6.18.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.19.0.tgz#9b2f244204777a3d6810ec127c673c87b349fac5" - dependencies: - babel-messages "^6.8.0" - babel-runtime "^6.9.0" - babel-types "^6.19.0" - detect-indent "^4.0.0" - jsesc "^1.3.0" - lodash "^4.2.0" - source-map "^0.5.0" - -babel-helper-bindify-decorators@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.18.0.tgz#fc00c573676a6e702fffa00019580892ec8780a5" - dependencies: - babel-runtime "^6.0.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helper-builder-binary-assignment-operator-visitor@^6.8.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.18.0.tgz#8ae814989f7a53682152e3401a04fabd0bb333a6" - dependencies: - babel-helper-explode-assignable-expression "^6.18.0" - babel-runtime "^6.0.0" - babel-types "^6.18.0" - -babel-helper-call-delegate@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.18.0.tgz#05b14aafa430884b034097ef29e9f067ea4133bd" - dependencies: - babel-helper-hoist-variables "^6.18.0" - babel-runtime "^6.0.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helper-define-map@^6.18.0, babel-helper-define-map@^6.8.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.18.0.tgz#8d6c85dc7fbb4c19be3de40474d18e97c3676ec2" - dependencies: - babel-helper-function-name "^6.18.0" - babel-runtime "^6.9.0" - babel-types "^6.18.0" - lodash "^4.2.0" - -babel-helper-explode-assignable-expression@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.18.0.tgz#14b8e8c2d03ad735d4b20f1840b24cd1f65239fe" - dependencies: - babel-runtime "^6.0.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helper-explode-class@^6.8.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-explode-class/-/babel-helper-explode-class-6.18.0.tgz#c44f76f4fa23b9c5d607cbac5d4115e7a76f62cb" - dependencies: - babel-helper-bindify-decorators "^6.18.0" - babel-runtime "^6.0.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helper-function-name@^6.18.0, babel-helper-function-name@^6.5.0, babel-helper-function-name@^6.8.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.18.0.tgz#68ec71aeba1f3e28b2a6f0730190b754a9bf30e6" - dependencies: - babel-helper-get-function-arity "^6.18.0" - babel-runtime "^6.0.0" - babel-template "^6.8.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helper-get-function-arity@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.18.0.tgz#a5b19695fd3f9cdfc328398b47dafcd7094f9f24" - dependencies: - babel-runtime "^6.0.0" - babel-types "^6.18.0" - -babel-helper-hoist-variables@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.18.0.tgz#a835b5ab8b46d6de9babefae4d98ea41e866b82a" - dependencies: - babel-runtime "^6.0.0" - babel-types "^6.18.0" - -babel-helper-optimise-call-expression@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.18.0.tgz#9261d0299ee1a4f08a6dd28b7b7c777348fd8f0f" - dependencies: - babel-runtime "^6.0.0" - babel-types "^6.18.0" - -babel-helper-regex@^6.8.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.18.0.tgz#ae0ebfd77de86cb2f1af258e2cc20b5fe893ecc6" - dependencies: - babel-runtime "^6.9.0" - babel-types "^6.18.0" - lodash "^4.2.0" - -babel-helper-remap-async-to-generator@^6.16.0, babel-helper-remap-async-to-generator@^6.16.2: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.18.0.tgz#336cdf3cab650bb191b02fc16a3708e7be7f9ce5" - dependencies: - babel-helper-function-name "^6.18.0" - babel-runtime "^6.0.0" - babel-template "^6.16.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helper-replace-supers@^6.18.0, babel-helper-replace-supers@^6.8.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.18.0.tgz#28ec69877be4144dbd64f4cc3a337e89f29a924e" - dependencies: - babel-helper-optimise-call-expression "^6.18.0" - babel-messages "^6.8.0" - babel-runtime "^6.0.0" - babel-template "^6.16.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-helpers@^6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.16.0.tgz#1095ec10d99279460553e67eb3eee9973d3867e3" - dependencies: - babel-runtime "^6.0.0" - babel-template "^6.16.0" - -babel-messages@^6.8.0: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.8.0.tgz#bf504736ca967e6d65ef0adb5a2a5f947c8e0eb9" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-check-es2015-constants@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.8.0.tgz#dbf024c32ed37bfda8dee1e76da02386a8d26fe7" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-coverage@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/babel-plugin-coverage/-/babel-plugin-coverage-1.0.0.tgz#ef008e238b6926cb837bf9692a4e645a6fd13a8c" - dependencies: - babel-helper-function-name "^6.5.0" - babel-template "^6.8.0" - -babel-plugin-syntax-async-functions@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" - -babel-plugin-syntax-async-generators@^6.5.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz#6bc963ebb16eccbae6b92b596eb7f35c342a8b9a" - -babel-plugin-syntax-class-constructor-call@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-class-constructor-call/-/babel-plugin-syntax-class-constructor-call-6.18.0.tgz#9cb9d39fe43c8600bec8146456ddcbd4e1a76416" - -babel-plugin-syntax-class-properties@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz#d7eb23b79a317f8543962c505b827c7d6cac27de" - -babel-plugin-syntax-decorators@^6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz#312563b4dbde3cc806cee3e416cceeaddd11ac0b" - -babel-plugin-syntax-do-expressions@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-do-expressions/-/babel-plugin-syntax-do-expressions-6.13.0.tgz#5747756139aa26d390d09410b03744ba07e4796d" - -babel-plugin-syntax-dynamic-import@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da" - -babel-plugin-syntax-exponentiation-operator@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" - -babel-plugin-syntax-export-extensions@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-export-extensions/-/babel-plugin-syntax-export-extensions-6.13.0.tgz#70a1484f0f9089a4e84ad44bac353c95b9b12721" - -babel-plugin-syntax-function-bind@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-function-bind/-/babel-plugin-syntax-function-bind-6.13.0.tgz#48c495f177bdf31a981e732f55adc0bdd2601f46" - -babel-plugin-syntax-object-rest-spread@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" - -babel-plugin-syntax-trailing-function-commas@^6.3.13: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.13.0.tgz#2b84b7d53dd744f94ff1fad7669406274b23f541" - -babel-plugin-transform-async-generator-functions@^6.17.0: - version "6.17.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.17.0.tgz#d0b5a2b2f0940f2b245fa20a00519ed7bc6cae54" - dependencies: - babel-helper-remap-async-to-generator "^6.16.2" - babel-plugin-syntax-async-generators "^6.5.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-async-to-generator@^6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.16.0.tgz#19ec36cb1486b59f9f468adfa42ce13908ca2999" - dependencies: - babel-helper-remap-async-to-generator "^6.16.0" - babel-plugin-syntax-async-functions "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-class-constructor-call@^6.3.13: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-class-constructor-call/-/babel-plugin-transform-class-constructor-call-6.18.0.tgz#80855e38a1ab47b8c6c647f8ea1bcd2c00ca3aae" - dependencies: - babel-plugin-syntax-class-constructor-call "^6.18.0" - babel-runtime "^6.0.0" - babel-template "^6.8.0" - -babel-plugin-transform-class-properties@^6.18.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.19.0.tgz#1274b349abaadc835164e2004f4a2444a2788d5f" - dependencies: - babel-helper-function-name "^6.18.0" - babel-plugin-syntax-class-properties "^6.8.0" - babel-runtime "^6.9.1" - babel-template "^6.15.0" - -babel-plugin-transform-decorators@^6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.13.0.tgz#82d65c1470ae83e2d13eebecb0a1c2476d62da9d" - dependencies: - babel-helper-define-map "^6.8.0" - babel-helper-explode-class "^6.8.0" - babel-plugin-syntax-decorators "^6.13.0" - babel-runtime "^6.0.0" - babel-template "^6.8.0" - babel-types "^6.13.0" - -babel-plugin-transform-do-expressions@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-do-expressions/-/babel-plugin-transform-do-expressions-6.8.0.tgz#fda692af339835cc255bb7544efb8f7c1306c273" - dependencies: - babel-plugin-syntax-do-expressions "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-arrow-functions@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.8.0.tgz#5b63afc3181bdc9a8c4d481b5a4f3f7d7fef3d9d" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-block-scoped-functions@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.8.0.tgz#ed95d629c4b5a71ae29682b998f70d9833eb366d" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-block-scoping@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.18.0.tgz#3bfdcfec318d46df22525cdea88f1978813653af" - dependencies: - babel-runtime "^6.9.0" - babel-template "^6.15.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - lodash "^4.2.0" - -babel-plugin-transform-es2015-classes@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.18.0.tgz#ffe7a17321bf83e494dcda0ae3fc72df48ffd1d9" - dependencies: - babel-helper-define-map "^6.18.0" - babel-helper-function-name "^6.18.0" - babel-helper-optimise-call-expression "^6.18.0" - babel-helper-replace-supers "^6.18.0" - babel-messages "^6.8.0" - babel-runtime "^6.9.0" - babel-template "^6.14.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-plugin-transform-es2015-computed-properties@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.8.0.tgz#f51010fd61b3bd7b6b60a5fdfd307bb7a5279870" - dependencies: - babel-helper-define-map "^6.8.0" - babel-runtime "^6.0.0" - babel-template "^6.8.0" - -babel-plugin-transform-es2015-destructuring@^6.18.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.19.0.tgz#ff1d911c4b3f4cab621bd66702a869acd1900533" - dependencies: - babel-runtime "^6.9.0" - -babel-plugin-transform-es2015-duplicate-keys@^6.6.0: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.8.0.tgz#fd8f7f7171fc108cc1c70c3164b9f15a81c25f7d" - dependencies: - babel-runtime "^6.0.0" - babel-types "^6.8.0" - -babel-plugin-transform-es2015-for-of@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.18.0.tgz#4c517504db64bf8cfc119a6b8f177211f2028a70" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-function-name@^6.9.0: - version "6.9.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.9.0.tgz#8c135b17dbd064e5bba56ec511baaee2fca82719" - dependencies: - babel-helper-function-name "^6.8.0" - babel-runtime "^6.9.0" - babel-types "^6.9.0" - -babel-plugin-transform-es2015-literals@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.8.0.tgz#50aa2e5c7958fc2ab25d74ec117e0cc98f046468" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-modules-amd@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.18.0.tgz#49a054cbb762bdf9ae2d8a807076cfade6141e40" - dependencies: - babel-plugin-transform-es2015-modules-commonjs "^6.18.0" - babel-runtime "^6.0.0" - babel-template "^6.8.0" - -babel-plugin-transform-es2015-modules-commonjs@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.18.0.tgz#c15ae5bb11b32a0abdcc98a5837baa4ee8d67bcc" - dependencies: - babel-plugin-transform-strict-mode "^6.18.0" - babel-runtime "^6.0.0" - babel-template "^6.16.0" - babel-types "^6.18.0" - -babel-plugin-transform-es2015-modules-systemjs@^6.18.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.19.0.tgz#50438136eba74527efa00a5b0fefaf1dc4071da6" - dependencies: - babel-helper-hoist-variables "^6.18.0" - babel-runtime "^6.11.6" - babel-template "^6.14.0" - -babel-plugin-transform-es2015-modules-umd@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.18.0.tgz#23351770ece5c1f8e83ed67cb1d7992884491e50" - dependencies: - babel-plugin-transform-es2015-modules-amd "^6.18.0" - babel-runtime "^6.0.0" - babel-template "^6.8.0" - -babel-plugin-transform-es2015-object-super@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.8.0.tgz#1b858740a5a4400887c23dcff6f4d56eea4a24c5" - dependencies: - babel-helper-replace-supers "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-parameters@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.18.0.tgz#9b2cfe238c549f1635ba27fc1daa858be70608b1" - dependencies: - babel-helper-call-delegate "^6.18.0" - babel-helper-get-function-arity "^6.18.0" - babel-runtime "^6.9.0" - babel-template "^6.16.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - -babel-plugin-transform-es2015-shorthand-properties@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.18.0.tgz#e2ede3b7df47bf980151926534d1dd0cbea58f43" - dependencies: - babel-runtime "^6.0.0" - babel-types "^6.18.0" - -babel-plugin-transform-es2015-spread@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.8.0.tgz#0217f737e3b821fa5a669f187c6ed59205f05e9c" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-sticky-regex@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.8.0.tgz#e73d300a440a35d5c64f5c2a344dc236e3df47be" - dependencies: - babel-helper-regex "^6.8.0" - babel-runtime "^6.0.0" - babel-types "^6.8.0" - -babel-plugin-transform-es2015-template-literals@^6.6.0: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.8.0.tgz#86eb876d0a2c635da4ec048b4f7de9dfc897e66b" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-typeof-symbol@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.18.0.tgz#0b14c48629c90ff47a0650077f6aa699bee35798" - dependencies: - babel-runtime "^6.0.0" - -babel-plugin-transform-es2015-unicode-regex@^6.3.13: - version "6.11.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.11.0.tgz#6298ceabaad88d50a3f4f392d8de997260f6ef2c" - dependencies: - babel-helper-regex "^6.8.0" - babel-runtime "^6.0.0" - regexpu-core "^2.0.0" - -babel-plugin-transform-exponentiation-operator@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.8.0.tgz#db25742e9339eade676ca9acec46f955599a68a4" - dependencies: - babel-helper-builder-binary-assignment-operator-visitor "^6.8.0" - babel-plugin-syntax-exponentiation-operator "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-export-extensions@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-export-extensions/-/babel-plugin-transform-export-extensions-6.8.0.tgz#fa80ff655b636549431bfd38f6b817bd82e47f5b" - dependencies: - babel-plugin-syntax-export-extensions "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-function-bind@^6.3.13: - version "6.8.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-function-bind/-/babel-plugin-transform-function-bind-6.8.0.tgz#e7f334ce69f50d28fe850a822eaaab9fa4f4d821" - dependencies: - babel-plugin-syntax-function-bind "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-object-rest-spread@^6.16.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.19.0.tgz#f6ac428ee3cb4c6aa00943ed1422ce813603b34c" - dependencies: - babel-plugin-syntax-object-rest-spread "^6.8.0" - babel-runtime "^6.0.0" - -babel-plugin-transform-regenerator@^6.16.0: - version "6.16.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.16.1.tgz#a75de6b048a14154aae14b0122756c5bed392f59" - dependencies: - babel-runtime "^6.9.0" - babel-types "^6.16.0" - private "~0.1.5" - -babel-plugin-transform-runtime@^6.15.0: - version "6.15.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-runtime/-/babel-plugin-transform-runtime-6.15.0.tgz#3d75b4d949ad81af157570273846fb59aeb0d57c" - dependencies: - babel-runtime "^6.9.0" - -babel-plugin-transform-strict-mode@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.18.0.tgz#df7cf2991fe046f44163dcd110d5ca43bc652b9d" - dependencies: - babel-runtime "^6.0.0" - babel-types "^6.18.0" - -babel-polyfill@^6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.16.0.tgz#2d45021df87e26a374b6d4d1a9c65964d17f2422" - dependencies: - babel-runtime "^6.9.1" - core-js "^2.4.0" - regenerator-runtime "^0.9.5" - -babel-preset-es2015@^6.16.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-preset-es2015/-/babel-preset-es2015-6.18.0.tgz#b8c70df84ec948c43dcf2bf770e988eb7da88312" - dependencies: - babel-plugin-check-es2015-constants "^6.3.13" - babel-plugin-transform-es2015-arrow-functions "^6.3.13" - babel-plugin-transform-es2015-block-scoped-functions "^6.3.13" - babel-plugin-transform-es2015-block-scoping "^6.18.0" - babel-plugin-transform-es2015-classes "^6.18.0" - babel-plugin-transform-es2015-computed-properties "^6.3.13" - babel-plugin-transform-es2015-destructuring "^6.18.0" - babel-plugin-transform-es2015-duplicate-keys "^6.6.0" - babel-plugin-transform-es2015-for-of "^6.18.0" - babel-plugin-transform-es2015-function-name "^6.9.0" - babel-plugin-transform-es2015-literals "^6.3.13" - babel-plugin-transform-es2015-modules-amd "^6.18.0" - babel-plugin-transform-es2015-modules-commonjs "^6.18.0" - babel-plugin-transform-es2015-modules-systemjs "^6.18.0" - babel-plugin-transform-es2015-modules-umd "^6.18.0" - babel-plugin-transform-es2015-object-super "^6.3.13" - babel-plugin-transform-es2015-parameters "^6.18.0" - babel-plugin-transform-es2015-shorthand-properties "^6.18.0" - babel-plugin-transform-es2015-spread "^6.3.13" - babel-plugin-transform-es2015-sticky-regex "^6.3.13" - babel-plugin-transform-es2015-template-literals "^6.6.0" - babel-plugin-transform-es2015-typeof-symbol "^6.18.0" - babel-plugin-transform-es2015-unicode-regex "^6.3.13" - babel-plugin-transform-regenerator "^6.16.0" - -babel-preset-stage-0@^6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-preset-stage-0/-/babel-preset-stage-0-6.16.0.tgz#f5a263c420532fd57491f1a7315b3036e428f823" - dependencies: - babel-plugin-transform-do-expressions "^6.3.13" - babel-plugin-transform-function-bind "^6.3.13" - babel-preset-stage-1 "^6.16.0" - -babel-preset-stage-1@^6.16.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-preset-stage-1/-/babel-preset-stage-1-6.16.0.tgz#9d31fbbdae7b17c549fd3ac93e3cf6902695e479" - dependencies: - babel-plugin-transform-class-constructor-call "^6.3.13" - babel-plugin-transform-export-extensions "^6.3.13" - babel-preset-stage-2 "^6.16.0" - -babel-preset-stage-2@^6.16.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-preset-stage-2/-/babel-preset-stage-2-6.18.0.tgz#9eb7bf9a8e91c68260d5ba7500493caaada4b5b5" - dependencies: - babel-plugin-syntax-dynamic-import "^6.18.0" - babel-plugin-transform-class-properties "^6.18.0" - babel-plugin-transform-decorators "^6.13.0" - babel-preset-stage-3 "^6.17.0" - -babel-preset-stage-3@^6.17.0: - version "6.17.0" - resolved "https://registry.yarnpkg.com/babel-preset-stage-3/-/babel-preset-stage-3-6.17.0.tgz#b6638e46db6e91e3f889013d8ce143917c685e39" - dependencies: - babel-plugin-syntax-trailing-function-commas "^6.3.13" - babel-plugin-transform-async-generator-functions "^6.17.0" - babel-plugin-transform-async-to-generator "^6.16.0" - babel-plugin-transform-exponentiation-operator "^6.3.13" - babel-plugin-transform-object-rest-spread "^6.16.0" - -babel-register@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.18.0.tgz#892e2e03865078dd90ad2c715111ec4449b32a68" - dependencies: - babel-core "^6.18.0" - babel-runtime "^6.11.6" - core-js "^2.4.0" - home-or-tmp "^2.0.0" - lodash "^4.2.0" - mkdirp "^0.5.1" - source-map-support "^0.4.2" - -babel-runtime@^6.0.0, babel-runtime@^6.11.6, babel-runtime@^6.9.0, babel-runtime@^6.9.1: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.18.0.tgz#0f4177ffd98492ef13b9f823e9994a02584c9078" - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.9.5" - -babel-template@^6.14.0, babel-template@^6.15.0, babel-template@^6.16.0, babel-template@^6.8.0: - version "6.16.0" - resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.16.0.tgz#e149dd1a9f03a35f817ddbc4d0481988e7ebc8ca" - dependencies: - babel-runtime "^6.9.0" - babel-traverse "^6.16.0" - babel-types "^6.16.0" - babylon "^6.11.0" - lodash "^4.2.0" - -babel-traverse@^6.15.0, babel-traverse@^6.16.0, babel-traverse@^6.18.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.19.0.tgz#68363fb821e26247d52a519a84b2ceab8df4f55a" - dependencies: - babel-code-frame "^6.16.0" - babel-messages "^6.8.0" - babel-runtime "^6.9.0" - babel-types "^6.19.0" - babylon "^6.11.0" - debug "^2.2.0" - globals "^9.0.0" - invariant "^2.2.0" - lodash "^4.2.0" - -babel-types@^6.13.0, babel-types@^6.15.0, babel-types@^6.16.0, babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.8.0, babel-types@^6.9.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.19.0.tgz#8db2972dbed01f1192a8b602ba1e1e4c516240b9" - dependencies: - babel-runtime "^6.9.1" - esutils "^2.0.2" - lodash "^4.2.0" - to-fast-properties "^1.0.1" - -babylon@^6.11.0, babylon@^6.13.0: - version "6.14.1" - resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.14.1.tgz#956275fab72753ad9b3435d7afe58f8bf0a29815" - -balanced-match@^0.4.1: - version "0.4.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" - -base64url@2.0.0, base64url@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" - -bcrypt-pbkdf@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz#3ca76b85241c7170bf7d9703e7b9aa74630040d4" - dependencies: - tweetnacl "^0.14.3" - -binary-extensions@^1.0.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.7.0.tgz#6c1610db163abfb34edfe42fa423343a1e01185d" - -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - dependencies: - inherits "~2.0.0" - -bluebird@2.10.2: - version "2.10.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.10.2.tgz#024a5517295308857f14f91f1106fc3b555f446b" - -bluebird@^3.1.5, bluebird@^3.3.3: - version "3.4.6" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" - -blueimp-md5@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.5.0.tgz#9766c8976b1141289a9b2210f313dea201f123cb" - -body-parser@^1.15.2: - version "1.15.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.15.2.tgz#d7578cf4f1d11d5f6ea804cef35dc7a7ff6dae67" - dependencies: - bytes "2.4.0" - content-type "~1.0.2" - debug "~2.2.0" - depd "~1.1.0" - http-errors "~1.5.0" - iconv-lite "0.4.13" - on-finished "~2.3.0" - qs "6.2.0" - raw-body "~2.1.7" - type-is "~1.6.13" - -boom@2.x.x: - version "2.10.1" - resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" - dependencies: - hoek "2.x.x" - -botbuilder@^3.8.4: - version "3.8.4" - resolved "https://registry.yarnpkg.com/botbuilder/-/botbuilder-3.8.4.tgz#fcedc42b927ecf050c24be520c1280824437a708" - dependencies: - async "^1.5.2" - base64url "^2.0.0" - chrono-node "^1.1.3" - jsonwebtoken "^7.0.1" - promise "^7.1.1" - request "^2.69.0" - rsa-pem-from-mod-exp "^0.8.4" - sprintf-js "^1.0.3" - url-join "^1.1.0" - -brace-expansion@^1.0.0: - version "1.1.6" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" - dependencies: - balanced-match "^0.4.1" - concat-map "0.0.1" - -braces@^1.8.2: - version "1.8.5" - resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" - dependencies: - expand-range "^1.8.1" - preserve "^0.2.0" - repeat-element "^1.1.2" - -browser-stdout@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f" - -bson@~0.5.4, bson@~0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/bson/-/bson-0.5.7.tgz#0d11fe0936c1fee029e11f7063f5d0ab2422ea3e" - -buffer-equal-constant-time@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" - -buffer-shims@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" - -bytes@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.4.0.tgz#7d97196f9d5baf7f6935e25985549edd2a6c2339" - -caller-id@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/caller-id/-/caller-id-0.1.0.tgz#59bdac0893d12c3871408279231f97458364f07b" - dependencies: - stack-trace "~0.0.7" - -caller-path@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" - dependencies: - callsites "^0.2.0" - -callr@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/callr/-/callr-1.0.0.tgz#70c0b7c61198259523eb4e30f2c8afa6a369b888" - -callsites@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" - -camelcase@^1.0.2: - version "1.2.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" - -caseless@~0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7" - -center-align@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" - dependencies: - align-text "^0.1.3" - lazy-cache "^1.0.3" - -chai-http@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/chai-http/-/chai-http-3.0.0.tgz#5460d8036e1f1a12b0b5b5cbd529e6dc1d31eb4b" - dependencies: - cookiejar "2.0.x" - is-ip "1.0.0" - methods "^1.1.2" - qs "^6.2.0" - superagent "^2.0.0" - -chai-spies@^0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/chai-spies/-/chai-spies-0.7.1.tgz#343d99f51244212e8b17e64b93996ff7b2c2a9b1" - -"chai@>=1.9.2 <4.0.0", chai@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247" - dependencies: - assertion-error "^1.0.1" - deep-eql "^0.1.3" - type-detect "^1.0.0" - -chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - -chalk@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f" - dependencies: - ansi-styles "~1.0.0" - has-color "~0.1.0" - strip-ansi "~0.1.0" - -chokidar@^1.0.0, chokidar@^1.4.3: - version "1.6.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.6.1.tgz#2f4447ab5e96e50fb3d789fd90d4c72e0e4c70c2" - dependencies: - anymatch "^1.3.0" - async-each "^1.0.0" - glob-parent "^2.0.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^2.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - optionalDependencies: - fsevents "^1.0.0" - -chrono-node@^1.1.3: - version "1.3.4" - resolved "https://registry.yarnpkg.com/chrono-node/-/chrono-node-1.3.4.tgz#fc2a9208636e09d6fd7b12d94ae2440937de24bd" - dependencies: - moment "^2.10.3" - -circular-json@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d" - -cli-cursor@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - dependencies: - restore-cursor "^1.0.1" - -cli-width@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" - -cliui@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" - dependencies: - center-align "^0.1.1" - right-align "^0.1.1" - wordwrap "0.0.2" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - -colors@1.0.x: - version "1.0.3" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - -combined-stream@^1.0.5, combined-stream@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" - dependencies: - delayed-stream "~1.0.0" - -commander@2.9.0, commander@^2.8.1, commander@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" - dependencies: - graceful-readlink ">= 1.0.0" - -component-emitter@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - -concat-stream@^1.4.6: - version "1.5.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" - dependencies: - inherits "~2.0.1" - readable-stream "~2.0.0" - typedarray "~0.0.5" - -configstore@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-1.4.0.tgz#c35781d0501d268c25c54b8b17f6240e8a4fb021" - dependencies: - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - object-assign "^4.0.1" - os-tmpdir "^1.0.0" - osenv "^0.1.0" - uuid "^2.0.1" - write-file-atomic "^1.1.2" - xdg-basedir "^2.0.0" - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - -content-disposition@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.1.tgz#87476c6a67c8daa87e32e87616df883ba7fb071b" - -content-type@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" - -convert-source-map@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.3.0.tgz#e9f3e9c6e2728efc2676696a70eb382f73106a67" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - -cookie@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - -cookiejar@2.0.x: - version "2.0.6" - resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.0.6.tgz#0abf356ad00d1c5a219d88d44518046dd026acfe" - -cookiejar@^2.0.6: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.0.tgz#86549689539b6d0e269b6637a304be508194d898" - -core-js@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" - -core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - -cors@^2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.1.tgz#6181aa56abb45a2825be3304703747ae4e9d2383" - dependencies: - vary "^1" - -cross-env@3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-3.1.3.tgz#58cd8231808f50089708b091f7dd37275a8e8154" - dependencies: - cross-spawn "^3.0.1" - -cross-spawn@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - -cryptiles@2.x.x: - version "2.0.5" - resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" - dependencies: - boom "2.x.x" - -crypto@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/crypto/-/crypto-0.0.3.tgz#470a81b86be4c5ee17acc8207a1f5315ae20dbb0" - -cycle@1.0.x: - version "1.0.3" - resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" - -d@^0.1.1, d@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" - dependencies: - es5-ext "~0.10.2" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - dependencies: - assert-plus "^1.0.0" - -debug@2, debug@2.2.0, debug@^2.1.1, debug@^2.2.0, debug@~2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" - dependencies: - ms "0.7.1" - -decamelize@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - -deep-eql@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2" - dependencies: - type-detect "0.1.1" - -deep-equal@^1.0.0, deep-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - -deep-extend@~0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - -del@^2.0.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" - dependencies: - globby "^5.0.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - rimraf "^2.2.8" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - -depd@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3" - -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - -detect-indent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" - dependencies: - repeating "^2.0.0" - -diff@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" - -doctrine@^1.2.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" - dependencies: - esutils "^2.0.2" - isarray "^1.0.0" - -duplexer@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" - -duplexify@^3.2.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.0.tgz#1aa773002e1578457e9d9d4a50b0ccaaebcbd604" - dependencies: - end-of-stream "1.0.0" - inherits "^2.0.1" - readable-stream "^2.0.0" - stream-shift "^1.0.0" - -ecc-jsbn@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" - dependencies: - jsbn "~0.1.0" - -ecdsa-sig-formatter@1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1" - dependencies: - base64url "^2.0.0" - safe-buffer "^5.0.1" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - -encodeurl@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" - -end-of-stream@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.0.0.tgz#d4596e702734a93e40e9af864319eabd99ff2f0e" - dependencies: - once "~1.3.0" - -entities@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" - -es5-ext@^0.10.7, es5-ext@^0.10.8, es5-ext@~0.10.11, es5-ext@~0.10.2, es5-ext@~0.10.7: - version "0.10.12" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.12.tgz#aa84641d4db76b62abba5e45fd805ecbab140047" - dependencies: - es6-iterator "2" - es6-symbol "~3.1" - -es6-iterator@2: - version "2.0.0" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac" - dependencies: - d "^0.1.1" - es5-ext "^0.10.7" - es6-symbol "3" - -es6-map@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-set "~0.1.3" - es6-symbol "~3.1.0" - event-emitter "~0.3.4" - -es6-promise@3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.2.1.tgz#ec56233868032909207170c39448e24449dd1fc4" - -es6-promise@^3.0.2: - version "3.3.1" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" - -es6-set@~0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-symbol "3" - event-emitter "~0.3.4" - -es6-symbol@3, es6-symbol@~3.1, es6-symbol@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - -es6-weak-map@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.1.tgz#0d2bbd8827eb5fb4ba8f97fbfea50d43db21ea81" - dependencies: - d "^0.1.1" - es5-ext "^0.10.8" - es6-iterator "2" - es6-symbol "3" - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - -escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - -escodegen@1.8.x: - version "1.8.1" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" - dependencies: - esprima "^2.7.1" - estraverse "^1.9.1" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.2.0" - -escope@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3" - dependencies: - es6-map "^0.1.3" - es6-weak-map "^2.0.1" - esrecurse "^4.1.0" - estraverse "^4.1.1" - -eslint-config-zavatta@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/eslint-config-zavatta/-/eslint-config-zavatta-4.2.0.tgz#8c98a92ad9f176d887f9166ea8f5ce1cd67043ea" - -eslint@^3.7.1: - version "3.10.2" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.10.2.tgz#c9a10e8bf6e9d65651204778c503341f1eac3ce7" - dependencies: - babel-code-frame "^6.16.0" - chalk "^1.1.3" - concat-stream "^1.4.6" - debug "^2.1.1" - doctrine "^1.2.2" - escope "^3.6.0" - espree "^3.3.1" - estraverse "^4.2.0" - esutils "^2.0.2" - file-entry-cache "^2.0.0" - glob "^7.0.3" - globals "^9.2.0" - ignore "^3.2.0" - imurmurhash "^0.1.4" - inquirer "^0.12.0" - is-my-json-valid "^2.10.0" - is-resolvable "^1.0.0" - js-yaml "^3.5.1" - json-stable-stringify "^1.0.0" - levn "^0.3.0" - lodash "^4.0.0" - mkdirp "^0.5.0" - natural-compare "^1.4.0" - optionator "^0.8.2" - path-is-inside "^1.0.1" - pluralize "^1.2.1" - progress "^1.1.8" - require-uncached "^1.0.2" - shelljs "^0.7.5" - strip-bom "^3.0.0" - strip-json-comments "~1.0.1" - table "^3.7.8" - text-table "~0.2.0" - user-home "^2.0.0" - -espree@^3.3.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-3.3.2.tgz#dbf3fadeb4ecb4d4778303e50103b3d36c88b89c" - dependencies: - acorn "^4.0.1" - acorn-jsx "^3.0.0" - -esprima@2.7.x, esprima@^2.6.0, esprima@^2.7.1: - version "2.7.3" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" - -esrecurse@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.1.0.tgz#4713b6536adf7f2ac4f327d559e7756bff648220" - dependencies: - estraverse "~4.1.0" - object-assign "^4.0.1" - -estraverse@^1.9.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" - -estraverse@^4.1.1, estraverse@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" - -estraverse@~4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.1.1.tgz#f6caca728933a850ef90661d0e17982ba47111a2" - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - -etag@~1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.7.0.tgz#03d30b5f67dd6e632d2945d30d6652731a34d5d8" - -event-emitter@~0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.4.tgz#8d63ddfb4cfe1fae3b32ca265c4c720222080bb5" - dependencies: - d "~0.1.1" - es5-ext "~0.10.7" - -event-stream@~3.3.0: - version "3.3.4" - resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" - dependencies: - duplexer "~0.1.1" - from "~0" - map-stream "~0.1.0" - pause-stream "0.0.11" - split "0.3" - stream-combiner "~0.0.4" - through "~2.3.1" - -eventemitter3@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" - -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - -expand-brackets@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" - dependencies: - is-posix-bracket "^0.1.0" - -expand-range@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" - dependencies: - fill-range "^2.1.0" - -express@^4.14.0: - version "4.14.0" - resolved "https://registry.yarnpkg.com/express/-/express-4.14.0.tgz#c1ee3f42cdc891fb3dc650a8922d51ec847d0d66" - dependencies: - accepts "~1.3.3" - array-flatten "1.1.1" - content-disposition "0.5.1" - content-type "~1.0.2" - cookie "0.3.1" - cookie-signature "1.0.6" - debug "~2.2.0" - depd "~1.1.0" - encodeurl "~1.0.1" - escape-html "~1.0.3" - etag "~1.7.0" - finalhandler "0.5.0" - fresh "0.3.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.1" - path-to-regexp "0.1.7" - proxy-addr "~1.1.2" - qs "6.2.0" - range-parser "~1.2.0" - send "0.14.1" - serve-static "~1.11.1" - type-is "~1.6.13" - utils-merge "1.0.0" - vary "~1.1.0" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - dependencies: - is-extendable "^0.1.0" - -extend@3, extend@^3.0.0, extend@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" - -extglob@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" - dependencies: - is-extglob "^1.0.0" - -extsprintf@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" - -eyes@0.1.x: - version "0.1.8" - resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" - -fast-levenshtein@~2.0.4: - version "2.0.5" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz#bd33145744519ab1c36c3ee9f31f08e9079b67f2" - -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - -file-entry-cache@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" - dependencies: - flat-cache "^1.2.1" - object-assign "^4.0.1" - -file-type@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" - -filename-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.0.tgz#996e3e80479b98b9897f15a8a58b3d084e926775" - -fill-range@^2.1.0: - version "2.2.3" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723" - dependencies: - is-number "^2.1.0" - isobject "^2.0.0" - randomatic "^1.1.3" - repeat-element "^1.1.2" - repeat-string "^1.5.2" - -filter-keys@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/filter-keys/-/filter-keys-1.1.0.tgz#e3851541c924695646f8c1fc4dcac91193b2e77b" - dependencies: - micromatch "^2.2.0" - -filter-object@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/filter-object/-/filter-object-2.1.0.tgz#af9c0ad0bb40a006946b84b4db33c3ae5e93df86" - dependencies: - extend-shallow "^2.0.1" - filter-keys "^1.0.2" - filter-values "^0.4.0" - kind-of "^2.0.1" - object.pick "^1.1.1" - -filter-values@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/filter-values/-/filter-values-0.4.0.tgz#f1b618dad908d0dd9906d27ca81620300ebf7a09" - dependencies: - for-own "^0.1.3" - is-match "^0.4.0" - -finalhandler@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.5.0.tgz#e9508abece9b6dba871a6942a1d7911b91911ac7" - dependencies: - debug "~2.2.0" - escape-html "~1.0.3" - on-finished "~2.3.0" - statuses "~1.3.0" - unpipe "~1.0.0" - -flat-cache@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.2.1.tgz#6c837d6225a7de5659323740b36d5361f71691ff" - dependencies: - circular-json "^0.3.0" - del "^2.0.2" - graceful-fs "^4.1.2" - write "^0.2.1" - -for-in@^0.1.5: - version "0.1.6" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8" - -for-own@^0.1.3, for-own@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.4.tgz#0149b41a39088c7515f51ebe1c1386d45f935072" - dependencies: - for-in "^0.1.5" - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - -form-data@1.0.0-rc4: - version "1.0.0-rc4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-1.0.0-rc4.tgz#05ac6bc22227b43e4461f488161554699d4f8b5e" - dependencies: - async "^1.5.2" - combined-stream "^1.0.5" - mime-types "^2.1.10" - -form-data@~2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.2.tgz#89c3534008b97eada4cbb157d58f6f5df025eae4" - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.5" - mime-types "^2.1.12" - -formatio@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9" - dependencies: - samsam "~1.1" - -formidable@^1.0.17: - version "1.0.17" - resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.0.17.tgz#ef5491490f9433b705faa77249c99029ae348559" - -forwarded@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" - -fresh@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" - -from@~0: - version "0.1.3" - resolved "https://registry.yarnpkg.com/from/-/from-0.1.3.tgz#ef63ac2062ac32acf7862e0d40b44b896f22f3bc" - -fs-extra@~0.28.0: - version "0.28.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.28.0.tgz#9a1c0708ea8c5169297ab06fd8cb914f5647b272" - dependencies: - graceful-fs "^4.1.2" - jsonfile "^2.1.0" - klaw "^1.0.0" - path-is-absolute "^1.0.0" - rimraf "^2.2.8" - -fs-readdir-recursive@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.0.0.tgz#8cd1745c8b4f8a29c8caec392476921ba195f560" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - -fsevents@^1.0.0: - version "1.0.15" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.0.15.tgz#fa63f590f3c2ad91275e4972a6cea545fb0aae44" - dependencies: - nan "^2.3.0" - node-pre-gyp "^0.6.29" - -fstream-ignore@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" - dependencies: - fstream "^1.0.0" - inherits "2" - minimatch "^3.0.0" - -fstream@^1.0.0, fstream@^1.0.2, fstream@~1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.10.tgz#604e8a92fe26ffd9f6fae30399d4984e1ab22822" - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - -gauge@~2.7.1: - version "2.7.1" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.1.tgz#388473894fe8be5e13ffcdb8b93e4ed0616428c7" - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-color "^0.1.7" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -generate-function@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" - -generate-object-property@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" - dependencies: - is-property "^1.0.0" - -getpass@^0.1.1: - version "0.1.6" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.6.tgz#283ffd9fc1256840875311c1b60e8c40187110e6" - dependencies: - assert-plus "^1.0.0" - -glob-base@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" - dependencies: - glob-parent "^2.0.0" - is-glob "^2.0.0" - -glob-parent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" - dependencies: - is-glob "^2.0.0" - -glob@7.0.5: - version "7.0.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.5.tgz#b4202a69099bbb4d292a7c1b95b6682b67ebdc95" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.2" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^5.0.15, glob@^5.0.5: - version "5.0.15" - resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" - dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5: - version "7.1.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.2" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^9.0.0, globals@^9.2.0: - version "9.14.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-9.14.0.tgz#8859936af0038741263053b39d0e76ca241e4034" - -globby@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" - dependencies: - array-union "^1.0.1" - arrify "^1.0.0" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -got@^3.2.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca" - dependencies: - duplexify "^3.2.0" - infinity-agent "^2.0.0" - is-redirect "^1.0.0" - is-stream "^1.0.0" - lowercase-keys "^1.0.0" - nested-error-stacks "^1.0.0" - object-assign "^3.0.0" - prepend-http "^1.0.0" - read-all-stream "^3.0.0" - timed-out "^2.0.0" - -graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9: - version "4.1.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" - -"graceful-readlink@>= 1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" - -growl@1.9.2: - version "1.9.2" - resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f" - -handlebars@^4.0.1: - version "4.0.6" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.6.tgz#2ce4484850537f9c97a8026d5399b935c4ed4ed7" - dependencies: - async "^1.4.0" - optimist "^0.6.1" - source-map "^0.4.4" - optionalDependencies: - uglify-js "^2.6" - -har-validator@~2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d" - dependencies: - chalk "^1.1.1" - commander "^2.9.0" - is-my-json-valid "^2.12.4" - pinkie-promise "^2.0.0" - -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - dependencies: - ansi-regex "^2.0.0" - -has-color@^0.1.7, has-color@~0.1.0: - version "0.1.7" - resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f" - -has-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - -hawk@~3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" - dependencies: - boom "2.x.x" - cryptiles "2.x.x" - hoek "2.x.x" - sntp "1.x.x" - -hoek@2.x.x: - version "2.16.3" - resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" - -home-or-tmp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.1" - -hooks-fixed@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/hooks-fixed/-/hooks-fixed-1.2.0.tgz#0d2772d4d7d685ff9244724a9f0b5b2559aac96b" - -http-errors@~1.5.0: - version "1.5.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.5.1.tgz#788c0d2c1de2c81b9e6e8c01843b6b97eb920750" - dependencies: - inherits "2.0.3" - setprototypeof "1.0.2" - statuses ">= 1.3.1 < 2" - -http-signature@~1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" - dependencies: - assert-plus "^0.2.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -https-proxy-agent@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz#35f7da6c48ce4ddbfa264891ac593ee5ff8671e6" - dependencies: - agent-base "2" - debug "2" - extend "3" - -iconv-lite@0.4.13, iconv-lite@^0.4.13: - version "0.4.13" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" - -ignore-by-default@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" - -ignore@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.0.tgz#8d88f03c3002a0ac52114db25d2c673b0bf1e435" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - -infinity-agent@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/infinity-agent/-/infinity-agent-2.0.3.tgz#45e0e2ff7a9eb030b27d62b74b3744b7a7ac4216" - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - -ini@~1.3.0: - version "1.3.4" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" - -inquirer@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e" - dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^2.0.0" - figures "^1.3.5" - lodash "^4.3.0" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - string-width "^1.0.1" - strip-ansi "^3.0.0" - through "^2.3.6" - -interpret@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" - -invariant@^2.2.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" - dependencies: - loose-envify "^1.0.0" - -ip-regex@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd" - -ipaddr.js@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.1.1.tgz#c791d95f52b29c1247d5df80ada39b8a73647230" - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.0.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.4.tgz#cfc86ccd5dc5a52fa80489111c6920c457e2d98b" - -is-dotfile@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.2.tgz#2c132383f39199f8edc268ca01b9b007d205cc4d" - -is-equal-shallow@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" - dependencies: - is-primitive "^2.0.0" - -is-extendable@^0.1.0, is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - -is-extglob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" - -is-finite@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - -is-glob@^2.0.0, is-glob@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" - dependencies: - is-extglob "^1.0.0" - -is-ip@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-1.0.0.tgz#2bb6959f797ccd6f9fdc812758bcbc87c4c59074" - dependencies: - ip-regex "^1.0.0" - -is-match@^0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/is-match/-/is-match-0.4.1.tgz#fb5f6c6709a1543b7c7efa7d9530e5b776f61f83" - dependencies: - deep-equal "^1.0.1" - is-extendable "^0.1.1" - is-glob "^2.0.1" - micromatch "^2.3.7" - -is-my-json-valid@^2.10.0, is-my-json-valid@^2.12.4: - version "2.15.0" - resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz#936edda3ca3c211fd98f3b2d3e08da43f7b2915b" - dependencies: - generate-function "^2.0.0" - generate-object-property "^1.1.0" - jsonpointer "^4.0.0" - xtend "^4.0.0" - -is-npm@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" - -is-number@^2.0.2, is-number@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" - dependencies: - kind-of "^3.0.2" - -is-path-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" - -is-path-in-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz#6477582b8214d602346094567003be8a9eac04dc" - dependencies: - is-path-inside "^1.0.0" - -is-path-inside@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.0.tgz#fc06e5a1683fbda13de667aff717bbc10a48f37f" - dependencies: - path-is-inside "^1.0.1" - -is-posix-bracket@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" - -is-primitive@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" - -is-property@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" - -is-redirect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" - -is-resolvable@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" - dependencies: - tryit "^1.0.1" - -is-stream@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - -is_js@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/is_js/-/is_js-0.9.0.tgz#0ab94540502ba7afa24c856aa985561669e9c52d" - -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - -isemail@1.x.x: - version "1.2.0" - resolved "https://registry.yarnpkg.com/isemail/-/isemail-1.2.0.tgz#be03df8cc3e29de4d2c5df6501263f1fa4595e9a" - -isexe@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0" - -isobject@^2.0.0, isobject@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - dependencies: - isarray "1.0.0" - -isstream@0.1.x, isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - -istanbul@^0.4.5: - version "0.4.5" - resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" - dependencies: - abbrev "1.0.x" - async "1.x" - escodegen "1.8.x" - esprima "2.7.x" - glob "^5.0.15" - handlebars "^4.0.1" - js-yaml "3.x" - mkdirp "0.5.x" - nopt "3.x" - once "1.x" - resolve "1.1.x" - supports-color "^3.1.0" - which "^1.1.1" - wordwrap "^1.0.0" - -jodid25519@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" - dependencies: - jsbn "~0.1.0" - -joi@^6.10.1: - version "6.10.1" - resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" - dependencies: - hoek "2.x.x" - isemail "1.x.x" - moment "2.x.x" - topo "1.x.x" - -js-tokens@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-2.0.0.tgz#79903f5563ee778cc1162e6dcf1a0027c97f9cb5" - -js-yaml@3.x, js-yaml@^3.5.1: - version "3.7.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" - dependencies: - argparse "^1.0.7" - esprima "^2.6.0" - -jsbn@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.0.tgz#650987da0dd74f4ebf5a11377a2aa2d273e97dfd" - -jsesc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - -json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" - dependencies: - jsonify "~0.0.0" - -json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - -json3@3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" - -json5@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.0.tgz#9b20715b026cbe3778fd769edccd822d8332a5b2" - -jsonfile@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonify@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" - -jsonpointer@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.0.tgz#6661e161d2fc445f19f98430231343722e1fcbd5" - -jsonwebtoken@^7.0.1: - version "7.4.1" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.1.tgz#7ca324f5215f8be039cd35a6c45bb8cb74a448fb" - dependencies: - joi "^6.10.1" - jws "^3.1.4" - lodash.once "^4.0.0" - ms "^2.0.0" - xtend "^4.0.1" - -jsprim@^1.2.2: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.3.1.tgz#2a7256f70412a29ee3670aaca625994c4dcff252" - dependencies: - extsprintf "1.0.2" - json-schema "0.2.3" - verror "1.3.6" - -jwa@^1.1.4: - version "1.1.5" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5" - dependencies: - base64url "2.0.0" - buffer-equal-constant-time "1.0.1" - ecdsa-sig-formatter "1.0.9" - safe-buffer "^5.0.1" - -jws@^3.1.4: - version "3.1.4" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2" - dependencies: - base64url "^2.0.0" - jwa "^1.1.4" - safe-buffer "^5.0.1" - -kareem@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/kareem/-/kareem-1.1.3.tgz#0877610d8879c38da62d1dbafde4e17f2692f041" - -kind-of@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" - dependencies: - is-buffer "^1.0.2" - -kind-of@^3.0.2: - version "3.0.4" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.0.4.tgz#7b8ecf18a4e17f8269d73b501c9f232c96887a74" - dependencies: - is-buffer "^1.0.2" - -klaw@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" - optionalDependencies: - graceful-fs "^4.1.9" - -latest-version@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-1.0.1.tgz#72cfc46e3e8d1be651e1ebb54ea9f6ea96f374bb" - dependencies: - package-json "^1.0.0" - -lazy-cache@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - -levn@^0.3.0, levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -linkify-it@~1.2.2: - version "1.2.4" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a" - dependencies: - uc.micro "^1.0.1" - -lodash._baseassign@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e" - dependencies: - lodash._basecopy "^3.0.0" - lodash.keys "^3.0.0" - -lodash._basecopy@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" - -lodash._basecreate@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz#1bc661614daa7fc311b7d03bf16806a0213cf821" - -lodash._bindcallback@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" - -lodash._createassigner@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11" - dependencies: - lodash._bindcallback "^3.0.0" - lodash._isiterateecall "^3.0.0" - lodash.restparam "^3.0.0" - -lodash._getnative@^3.0.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" - -lodash._isiterateecall@^3.0.0: - version "3.0.9" - resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" - -lodash.assign@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa" - dependencies: - lodash._baseassign "^3.0.0" - lodash._createassigner "^3.0.0" - lodash.keys "^3.0.0" - -lodash.create@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7" - dependencies: - lodash._baseassign "^3.0.0" - lodash._basecreate "^3.0.0" - lodash._isiterateecall "^3.0.0" - -lodash.defaults@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-3.1.2.tgz#c7308b18dbf8bc9372d701a73493c61192bd2e2c" - dependencies: - lodash.assign "^3.0.0" - lodash.restparam "^3.0.0" - -lodash.isarguments@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" - -lodash.isarray@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" - -lodash.keys@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" - dependencies: - lodash._getnative "^3.0.0" - lodash.isarguments "^3.0.0" - lodash.isarray "^3.0.0" - -lodash.once@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" - -lodash.pickby@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" - -lodash.restparam@^3.0.0: - version "3.6.1" - resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" - -lodash@4.13.1: - version "4.13.1" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.13.1.tgz#83e4b10913f48496d4d16fec4a560af2ee744b68" - -lodash@^4.0.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.16.4, lodash@^4.2.0, lodash@^4.3.0: - version "4.17.2" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.2.tgz#34a3055babe04ce42467b607d700072c7ff6bf42" - -lodash@~4.11.1: - version "4.11.2" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.11.2.tgz#d6b4338b110a58e21dae5cebcfdbbfd2bc4cdb3b" - -lodash@~4.9.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.9.0.tgz#4c20d742f03ce85dc700e0dd7ab9bcab85e6fc14" - -lolex@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31" - -longest@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - -loose-envify@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.0.tgz#6b26248c42f6d4fa4b0d8542f78edfcde35642a8" - dependencies: - js-tokens "^2.0.0" - -lowercase-keys@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" - -lru-cache@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" - dependencies: - pseudomap "^1.0.1" - yallist "^2.0.0" - -map-stream@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" - -markdown-it@^6.0.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-6.1.1.tgz#ced037f4473ee9f5153ac414f77dc83c91ba927c" - dependencies: - argparse "^1.0.7" - entities "~1.1.1" - linkify-it "~1.2.2" - mdurl "~1.0.1" - uc.micro "^1.0.1" - -mdurl@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - -methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - -micromatch@^2.1.5, micromatch@^2.2.0, micromatch@^2.3.7: - version "2.3.11" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" - dependencies: - arr-diff "^2.0.0" - array-unique "^0.2.1" - braces "^1.8.2" - expand-brackets "^0.1.4" - extglob "^0.3.1" - filename-regex "^2.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.1" - kind-of "^3.0.2" - normalize-path "^2.0.1" - object.omit "^2.0.0" - parse-glob "^3.0.4" - regex-cache "^0.4.2" - -mime-db@~1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" - -mime-types@^2.1.10, mime-types@^2.1.12, mime-types@^2.1.14, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: - version "2.1.15" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" - dependencies: - mime-db "~1.27.0" - -mime@1.3.4, mime@^1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" - -"minimatch@2 || 3", minimatch@3.0.2, minimatch@^3.0.0, minimatch@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.2.tgz#0f398a7300ea441e9c348c83d98ab8c9dbf9c40a" - dependencies: - brace-expansion "^1.0.0" - -minimist@0.0.8, minimist@~0.0.1: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - -minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - -mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - dependencies: - minimist "0.0.8" - -mocha@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.1.2.tgz#51f93b432bf7e1b175ffc22883ccd0be32dba6b5" - dependencies: - browser-stdout "1.3.0" - commander "2.9.0" - debug "2.2.0" - diff "1.4.0" - escape-string-regexp "1.0.5" - glob "7.0.5" - growl "1.9.2" - json3 "3.3.2" - lodash.create "3.1.1" - mkdirp "0.5.1" - supports-color "3.1.2" - -mock-require@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/mock-require/-/mock-require-1.3.0.tgz#826144952e504762f8e6924aa8f639465d1d7a24" - dependencies: - caller-id "^0.1.0" - -moment@2.x.x, moment@^2.10.3: - version "2.18.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" - -mongodb-core@2.0.13: - version "2.0.13" - resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.0.13.tgz#f9394b588dce0e579482e53d74dbc7d7a9d4519c" - dependencies: - bson "~0.5.6" - require_optional "~1.0.0" - -mongodb@2.2.11: - version "2.2.11" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-2.2.11.tgz#a828b036fe6a437a35e723af5f81781c4976306c" - dependencies: - es6-promise "3.2.1" - mongodb-core "2.0.13" - readable-stream "2.1.5" - -mongoose@^4.6.3: - version "4.7.0" - resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.7.0.tgz#adb7c6b73dfb76f204a70ba6cd364f887dcc2012" - dependencies: - async "2.1.2" - bson "~0.5.4" - hooks-fixed "1.2.0" - kareem "1.1.3" - mongodb "2.2.11" - mpath "0.2.1" - mpromise "0.5.5" - mquery "2.0.0" - ms "0.7.1" - muri "1.1.1" - regexp-clone "0.0.1" - sliced "1.0.1" - -mpath@0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.2.1.tgz#3a4e829359801de96309c27a6b2e102e89f9e96e" - -mpromise@0.5.5: - version "0.5.5" - resolved "https://registry.yarnpkg.com/mpromise/-/mpromise-0.5.5.tgz#f5b24259d763acc2257b0a0c8c6d866fd51732e6" - -mquery@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mquery/-/mquery-2.0.0.tgz#b5abc850b90dffc3e10ae49b4b6e7a479752df22" - dependencies: - bluebird "2.10.2" - debug "2.2.0" - regexp-clone "0.0.1" - sliced "0.0.5" - -ms@0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" - -ms@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - -muri@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/muri/-/muri-1.1.1.tgz#64bd904eaf8ff89600c994441fad3c5195905ac2" - -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - -nan@^2.3.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232" - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - -negotiator@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" - -nested-error-stacks@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-1.0.2.tgz#19f619591519f096769a5ba9a86e6eeec823c3cf" - dependencies: - inherits "~2.0.1" - -nock@^8.1.0: - version "8.2.1" - resolved "https://registry.yarnpkg.com/nock/-/nock-8.2.1.tgz#64cc65e1bdd3893f58cba7e1abfdc38f40f0364a" - dependencies: - chai ">=1.9.2 <4.0.0" - debug "^2.2.0" - deep-equal "^1.0.0" - json-stringify-safe "^5.0.1" - lodash "~4.9.0" - mkdirp "^0.5.0" - propagate "0.4.0" - qs "^6.0.2" - -node-pre-gyp@^0.6.29: - version "0.6.31" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.31.tgz#d8a00ddaa301a940615dbcc8caad4024d58f6017" - dependencies: - mkdirp "~0.5.1" - nopt "~3.0.6" - npmlog "^4.0.0" - rc "~1.1.6" - request "^2.75.0" - rimraf "~2.5.4" - semver "~5.3.0" - tar "~2.2.1" - tar-pack "~3.3.0" - -node-sparky@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/node-sparky/-/node-sparky-4.0.8.tgz#d05510548e5da111df93e21c9d57b4e45e46799d" - dependencies: - lodash "4.13.1" - mime-types "^2.1.14" - request "^2.79.0" - when "3.7.7" - -nodemon@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.11.0.tgz#226c562bd2a7b13d3d7518b49ad4828a3623d06c" - dependencies: - chokidar "^1.4.3" - debug "^2.2.0" - es6-promise "^3.0.2" - ignore-by-default "^1.0.0" - lodash.defaults "^3.1.2" - minimatch "^3.0.0" - ps-tree "^1.0.1" - touch "1.0.0" - undefsafe "0.0.3" - update-notifier "0.5.0" - -nomnom@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7" - dependencies: - chalk "~0.4.0" - underscore "~1.6.0" - -nopt@3.x, nopt@~3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" - dependencies: - abbrev "1" - -nopt@~1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" - dependencies: - abbrev "1" - -normalize-path@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.0.1.tgz#47886ac1662760d4261b7d979d241709d3ce3f7a" - -npmlog@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.1.tgz#d14f503b4cd79710375553004ba96e6662fbc0b8" - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.1" - set-blocking "~2.0.0" - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - -oauth-sign@~0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" - -object-assign@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" - -object-assign@^4.0.1, object-assign@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" - -object.omit@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" - dependencies: - for-own "^0.1.4" - is-extendable "^0.1.1" - -object.pick@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.2.0.tgz#b5392bee9782da6d9fb7d6afaf539779f1234c2b" - dependencies: - isobject "^2.1.0" - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - dependencies: - ee-first "1.1.1" - -once@1.x, once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - dependencies: - wrappy "1" - -once@~1.3.0, once@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" - dependencies: - wrappy "1" - -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - -optimist@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" - dependencies: - minimist "~0.0.1" - wordwrap "~0.0.2" - -optionator@^0.8.1, optionator@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.4" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - wordwrap "~1.0.0" - -options@>=0.0.5: - version "0.0.6" - resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f" - -os-homedir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - -os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - -osenv@^0.1.0: - version "0.1.3" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.3.tgz#83cf05c6d6458fc4d5ac6362ea325d92f2754217" - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -output-file-sync@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-1.1.2.tgz#d0a33eefe61a205facb90092e826598d5245ce76" - dependencies: - graceful-fs "^4.1.4" - mkdirp "^0.5.1" - object-assign "^4.1.0" - -package-json@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-1.2.0.tgz#c8ecac094227cdf76a316874ed05e27cc939a0e0" - dependencies: - got "^3.2.0" - registry-url "^3.0.0" - -parse-glob@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" - dependencies: - glob-base "^0.3.0" - is-dotfile "^1.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.0" - -parseurl@~1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56" - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - -path-is-inside@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - -pause-stream@0.0.11: - version "0.0.11" - resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" - dependencies: - through "~2.3" - -pify@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - -pkginfo@0.3.x: - version "0.3.1" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" - -pluralize@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - -prepend-http@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" - -preserve@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" - -private@^0.1.6, private@~0.1.5: - version "0.1.6" - resolved "https://registry.yarnpkg.com/private/-/private-0.1.6.tgz#55c6a976d0f9bafb9924851350fe47b9b5fbb7c1" - -process-nextick-args@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" - -progress@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" - -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - dependencies: - asap "~2.0.3" - -propagate@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/propagate/-/propagate-0.4.0.tgz#f3fcca0a6fe06736a7ba572966069617c130b481" - -proxy-addr@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.2.tgz#b4cc5f22610d9535824c123aef9d3cf73c40ba37" - dependencies: - forwarded "~0.1.0" - ipaddr.js "1.1.1" - -ps-tree@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014" - dependencies: - event-stream "~3.3.0" - -pseudomap@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - -punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - -qs@6.2.0, qs@^6.0.2, qs@^6.1.0, qs@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b" - -qs@~6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" - -randomatic@^1.1.3: - version "1.1.5" - resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.5.tgz#5e9ef5f2d573c67bd2b8124ae90b5156e457840b" - dependencies: - is-number "^2.0.2" - kind-of "^3.0.2" - -range-parser@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - -raw-body@~2.1.7: - version "2.1.7" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.1.7.tgz#adfeace2e4fb3098058014d08c072dcc59758774" - dependencies: - bytes "2.4.0" - iconv-lite "0.4.13" - unpipe "1.0.0" - -rc@^1.0.1, rc@~1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.1.6.tgz#43651b76b6ae53b5c802f1151fa3fc3b059969c9" - dependencies: - deep-extend "~0.4.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~1.0.4" - -read-all-stream@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa" - dependencies: - pinkie-promise "^2.0.0" - readable-stream "^2.0.0" - -readable-stream@2.1.5, readable-stream@~2.1.4: - version "2.1.5" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" - dependencies: - buffer-shims "^1.0.0" - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - string_decoder "~0.10.x" - util-deprecate "~1.0.1" - -readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.2, readable-stream@^2.0.5: - version "2.2.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.2.tgz#a9e6fec3c7dda85f8bb1b3ba7028604556fc825e" - dependencies: - buffer-shims "^1.0.0" - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - string_decoder "~0.10.x" - util-deprecate "~1.0.1" - -readable-stream@~2.0.0: - version "2.0.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - string_decoder "~0.10.x" - util-deprecate "~1.0.1" - -readdirp@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" - dependencies: - graceful-fs "^4.1.2" - minimatch "^3.0.2" - readable-stream "^2.0.2" - set-immediate-shim "^1.0.1" - -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" - -rechoir@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" - dependencies: - resolve "^1.1.6" - -recursive-readdir@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.1.0.tgz#78b7bfd79582d3d7596b8ff1bd29fbd50229f6aa" - dependencies: - minimatch "3.0.2" - -regenerate@^1.2.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.2.tgz#d1941c67bad437e1be76433add5b385f95b19260" - -regenerator-runtime@^0.9.5: - version "0.9.6" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz#d33eb95d0d2001a4be39659707c51b0cb71ce029" - -regex-cache@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.3.tgz#9b1a6c35d4d0dfcef5711ae651e8e9d3d7114145" - dependencies: - is-equal-shallow "^0.1.3" - is-primitive "^2.0.0" - -regexp-clone@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-0.0.1.tgz#a7c2e09891fdbf38fbb10d376fb73003e68ac589" - -regexpu-core@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240" - dependencies: - regenerate "^1.2.1" - regjsgen "^0.2.0" - regjsparser "^0.1.4" - -registry-url@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" - dependencies: - rc "^1.0.1" - -regjsgen@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" - -regjsparser@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" - dependencies: - jsesc "~0.5.0" - -repeat-element@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a" - -repeat-string@^1.5.2: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - -repeating@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-1.1.3.tgz#3d4114218877537494f97f77f9785fab810fa4ac" - dependencies: - is-finite "^1.0.0" - -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - dependencies: - is-finite "^1.0.0" - -request@^2.64.0, request@^2.68.0, request@^2.69.0, request@^2.75.0, request@^2.79.0: - version "2.79.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" - dependencies: - aws-sign2 "~0.6.0" - aws4 "^1.2.1" - caseless "~0.11.0" - combined-stream "~1.0.5" - extend "~3.0.0" - forever-agent "~0.6.1" - form-data "~2.1.1" - har-validator "~2.0.6" - hawk "~3.1.3" - http-signature "~1.1.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.7" - oauth-sign "~0.8.1" - qs "~6.3.0" - stringstream "~0.0.4" - tough-cookie "~2.3.0" - tunnel-agent "~0.4.1" - uuid "^3.0.0" - -require-uncached@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" - dependencies: - caller-path "^0.1.0" - resolve-from "^1.0.0" - -require_optional@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.0.tgz#52a86137a849728eb60a55533617f8f914f59abf" - dependencies: - resolve-from "^2.0.0" - semver "^5.1.0" - -resolve-from@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" - -resolve-from@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" - -resolve@1.1.x, resolve@^1.1.6: - version "1.1.7" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" - -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" - -retry@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.9.0.tgz#6f697e50a0e4ddc8c8f7fb547a9b60dead43678d" - -right-align@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" - dependencies: - align-text "^0.1.1" - -rimraf@2, rimraf@^2.2.8, rimraf@~2.5.1, rimraf@~2.5.4: - version "2.5.4" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.5.4.tgz#96800093cbf1a0c86bd95b4625467535c29dfa04" - dependencies: - glob "^7.0.5" - -rsa-pem-from-mod-exp@^0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.4.tgz#362a42c6d304056d493b3f12bceabb2c6576a6d4" - -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - dependencies: - once "^1.3.0" - -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - -safe-buffer@^5.0.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - -samsam@1.1.2, samsam@~1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" - -semver-diff@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" - dependencies: - semver "^5.0.3" - -semver@^5.0.3, semver@^5.1.0, semver@~5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.1.1.tgz#a3292a373e6f3e0798da0b20641b9a9c5bc47e19" - -semver@~5.0.1: - version "5.0.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.0.3.tgz#77466de589cd5d3c95f138aa78bc569a3cb5d27a" - -semver@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - -send@0.14.1: - version "0.14.1" - resolved "https://registry.yarnpkg.com/send/-/send-0.14.1.tgz#a954984325392f51532a7760760e459598c89f7a" - dependencies: - debug "~2.2.0" - depd "~1.1.0" - destroy "~1.0.4" - encodeurl "~1.0.1" - escape-html "~1.0.3" - etag "~1.7.0" - fresh "0.3.0" - http-errors "~1.5.0" - mime "1.3.4" - ms "0.7.1" - on-finished "~2.3.0" - range-parser "~1.2.0" - statuses "~1.3.0" - -serve-static@~1.11.1: - version "1.11.1" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.11.1.tgz#d6cce7693505f733c759de57befc1af76c0f0805" - dependencies: - encodeurl "~1.0.1" - escape-html "~1.0.3" - parseurl "~1.3.1" - send "0.14.1" - -set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - -set-immediate-shim@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" - -setprototypeof@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.2.tgz#81a552141ec104b88e89ce383103ad5c66564d08" - -shelljs@^0.7.5: - version "0.7.5" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.5.tgz#2eef7a50a21e1ccf37da00df767ec69e30ad0675" - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - -signal-exit@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.1.tgz#5a4c884992b63a7acd9badb7894c3ee9cfccad81" - -sinon@^1.17.6: - version "1.17.6" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.6.tgz#a43116db59577c8296356afee13fafc2332e58e1" - dependencies: - formatio "1.1.1" - lolex "1.3.2" - samsam "1.1.2" - util ">=0.10.3 <1" - -slash@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" - -slice-ansi@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" - -sliced@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/sliced/-/sliced-0.0.5.tgz#5edc044ca4eb6f7816d50ba2fc63e25d8fe4707f" - -sliced@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" - -slide@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" - -sntp@1.x.x: - version "1.0.9" - resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" - dependencies: - hoek "2.x.x" - -source-map-support@^0.4.2: - version "0.4.6" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.6.tgz#32552aa64b458392a85eab3b0b5ee61527167aeb" - dependencies: - source-map "^0.5.3" - -source-map@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" - dependencies: - amdefine ">=0.0.4" - -source-map@^0.5.0, source-map@^0.5.3, source-map@~0.5.1: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - -source-map@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" - dependencies: - amdefine ">=0.0.4" - -split@0.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" - dependencies: - through "2" - -sprintf-js@^1.0.3, sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - -sshpk@^1.7.0: - version "1.10.1" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.1.tgz#30e1a5d329244974a1af61511339d595af6638b0" - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - dashdash "^1.12.0" - getpass "^0.1.1" - optionalDependencies: - bcrypt-pbkdf "^1.0.0" - ecc-jsbn "~0.1.1" - jodid25519 "^1.0.0" - jsbn "~0.1.0" - tweetnacl "~0.14.0" - -stack-trace@0.0.x, stack-trace@~0.0.7: - version "0.0.9" - resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.9.tgz#a8f6eaeca90674c333e7c43953f275b451510695" - -"statuses@>= 1.3.1 < 2", statuses@~1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" - -stream-combiner@~0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" - dependencies: - duplexer "~0.1.1" - -stream-shift@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" - -string-length@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-1.0.1.tgz#56970fb1c38558e9e70b728bf3de269ac45adfac" - dependencies: - strip-ansi "^3.0.0" - -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -string-width@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.0.0.tgz#635c5436cc72a6e0c387ceca278d4e2eec52687e" - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^3.0.0" - -string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - -stringstream@~0.0.4: - version "0.0.5" - resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - -strip-json-comments@~1.0.1, strip-json-comments@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91" - -superagent-promise@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/superagent-promise/-/superagent-promise-1.1.0.tgz#baf22d8bbdd439a9b07dd10f8c08f54fe2503533" - -superagent@^2.0.0, superagent@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/superagent/-/superagent-2.3.0.tgz#703529a0714e57e123959ddefbce193b2e50d115" - dependencies: - component-emitter "^1.2.0" - cookiejar "^2.0.6" - debug "^2.2.0" - extend "^3.0.0" - form-data "1.0.0-rc4" - formidable "^1.0.17" - methods "^1.1.1" - mime "^1.3.4" - qs "^6.1.0" - readable-stream "^2.0.5" - -supports-color@3.1.2, supports-color@^3.1.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5" - dependencies: - has-flag "^1.0.0" - -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - -table@^3.7.8: - version "3.8.3" - resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f" - dependencies: - ajv "^4.7.0" - ajv-keywords "^1.0.0" - chalk "^1.1.1" - lodash "^4.0.0" - slice-ansi "0.0.4" - string-width "^2.0.0" - -tar-pack@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.3.0.tgz#30931816418f55afc4d21775afdd6720cee45dae" - dependencies: - debug "~2.2.0" - fstream "~1.0.10" - fstream-ignore "~1.0.5" - once "~1.3.3" - readable-stream "~2.1.4" - rimraf "~2.5.1" - tar "~2.2.1" - uid-number "~0.0.6" - -tar@~2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" - dependencies: - block-stream "*" - fstream "^1.0.2" - inherits "2" - -text-table@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - -through@2, through@^2.3.6, through@~2.3, through@~2.3.1: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - -timed-out@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-2.0.0.tgz#f38b0ae81d3747d628001f41dafc652ace671c0a" - -tmp@^0.0.31: - version "0.0.31" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" - dependencies: - os-tmpdir "~1.0.1" - -to-fast-properties@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.2.tgz#f3f5c0c3ba7299a7ef99427e44633257ade43320" - -topo@1.x.x: - version "1.1.0" - resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" - dependencies: - hoek "2.x.x" - -touch@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de" - dependencies: - nopt "~1.0.10" - -tough-cookie@~2.3.0: - version "2.3.2" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" - dependencies: - punycode "^1.4.1" - -tryit@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" - -tsscmp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.5.tgz#7dc4a33af71581ab4337da91d85ca5427ebd9a97" - -tunnel-agent@~0.4.1: - version "0.4.3" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.3" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.3.tgz#3da382f670f25ded78d7b3d1792119bca0b7132d" - -"twit@git://github.com/dbousque/twit.git": - version "2.2.5" - resolved "git://github.com/dbousque/twit.git#38c4ce541169f2b2bec7519ccfd81abf97d04095" - dependencies: - bluebird "^3.1.5" - mime "^1.3.4" - request "^2.68.0" - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - dependencies: - prelude-ls "~1.1.2" - -type-detect@0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" - -type-detect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2" - -type-is@~1.6.13: - version "1.6.14" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.14.tgz#e219639c17ded1ca0789092dd54a03826b817cb2" - dependencies: - media-typer "0.3.0" - mime-types "~2.1.13" - -typedarray@~0.0.5: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - -uc.micro@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192" - -uglify-js@^2.6: - version "2.7.4" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.4.tgz#a295a0de12b6a650c031c40deb0dc40b14568bd2" - dependencies: - async "~0.2.6" - source-map "~0.5.1" - uglify-to-browserify "~1.0.0" - yargs "~3.10.0" - -uglify-to-browserify@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" - -uid-number@~0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" - -ultron@1.0.x: - version "1.0.2" - resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" - -undefsafe@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-0.0.3.tgz#ecca3a03e56b9af17385baac812ac83b994a962f" - -underscore@~1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8" - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - -update-notifier@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-0.5.0.tgz#07b5dc2066b3627ab3b4f530130f7eddda07a4cc" - dependencies: - chalk "^1.0.0" - configstore "^1.0.0" - is-npm "^1.0.0" - latest-version "^1.0.0" - repeating "^1.1.2" - semver-diff "^2.0.0" - string-length "^1.0.0" - -url-join@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8" - -url-join@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/url-join/-/url-join-1.1.0.tgz#741c6c2f4596c4830d6718460920d0c92202dc78" - -user-home@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" - -user-home@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f" - dependencies: - os-homedir "^1.0.0" - -util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - -"util@>=0.10.3 <1": - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - dependencies: - inherits "2.0.1" - -utils-merge@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" - -uuid@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" - -uuid@^3.0.0, uuid@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" - -v8flags@^2.0.10: - version "2.0.11" - resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.0.11.tgz#bca8f30f0d6d60612cc2c00641e6962d42ae6881" - dependencies: - user-home "^1.1.1" - -vary@^1, vary@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.0.tgz#e1e5affbbd16ae768dd2674394b9ad3022653140" - -verror@1.3.6: - version "1.3.6" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.3.6.tgz#cff5df12946d297d2baaefaa2689e25be01c005c" - dependencies: - extsprintf "1.0.2" - -when@3.7.7: - version "3.7.7" - resolved "https://registry.yarnpkg.com/when/-/when-3.7.7.tgz#aba03fc3bb736d6c88b091d013d8a8e590d84718" - -which@^1.1.1, which@^1.2.9: - version "1.2.12" - resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" - dependencies: - isexe "^1.1.1" - -wide-align@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.0.tgz#40edde802a71fea1f070da3e62dcda2e7add96ad" - dependencies: - string-width "^1.0.1" - -window-size@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" - -winston@^2.1.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-2.3.0.tgz#207faaab6fccf3fe493743dd2b03dbafc7ceb78c" - dependencies: - async "~1.0.0" - colors "1.0.x" - cycle "1.0.x" - eyes "0.1.x" - isstream "0.1.x" - stack-trace "0.0.x" - -winston@~2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-2.2.0.tgz#2c853dd87ab552a8e8485d72cbbf9a2286f029b7" - dependencies: - async "~1.0.0" - colors "1.0.x" - cycle "1.0.x" - eyes "0.1.x" - isstream "0.1.x" - pkginfo "0.3.x" - stack-trace "0.0.x" - -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - -wordwrap@^1.0.0, wordwrap@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - -wordwrap@~0.0.2: - version "0.0.3" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - -wrench@~1.5.9: - version "1.5.9" - resolved "https://registry.yarnpkg.com/wrench/-/wrench-1.5.9.tgz#411691c63a9b2531b1700267279bdeca23b2142a" - -write-file-atomic@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.2.0.tgz#14c66d4e4cb3ca0565c28cf3b7a6f3e4d5938fab" - dependencies: - graceful-fs "^4.1.2" - imurmurhash "^0.1.4" - slide "^1.1.5" - -write@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" - dependencies: - mkdirp "^0.5.1" - -ws@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.1.tgz#082ddb6c641e85d4bb451f03d52f06eabdb1f018" - dependencies: - options ">=0.0.5" - ultron "1.0.x" - -xdg-basedir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2" - dependencies: - os-homedir "^1.0.0" - -xtend@^4.0.0, xtend@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" - -yallist@^2.0.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - -yargs@~3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" - dependencies: - camelcase "^1.0.2" - cliui "^2.1.0" - decamelize "^1.0.0" - window-size "0.1.0" From 3c53b977de24a1965b0d1545d0e8f7c216f46721 Mon Sep 17 00:00:00 2001 From: Rene Springer Date: Thu, 7 Mar 2019 17:39:34 -0800 Subject: [PATCH 32/37] update README and add example/ --- .gitignore | 5 ++++- README.md | 14 +++++++------- example/bot.js | 39 +++++++++++++++++++++++++++++++++++++++ example/package.json | 14 ++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 example/bot.js create mode 100644 example/package.json diff --git a/.gitignore b/.gitignore index 6f10471..11f808e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ node_modules npm-debug.log dist coverage -doc +docs +config/* +-config/index.js +-config/test.js diff --git a/README.md b/README.md index dd8e472..556b5ee 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Bot Connector supports the following channels: * [Telegram](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Telegram) * [Twilio](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Twilio) * [Cisco Webex](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Cisco) -* [Microsoft Bot Framework (Skype, Teams, Cortana,...)](https://github.com/RecastAI/bot-connector/wiki/Channel-Microsoft-Bot-Framework) +* [Microsoft Bot Framework (Skype, Teams, Cortana,...)](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Microsoft-Bot-Framework) * [Twitter](https://github.com/SAPConversationalAI/bot-connector/wiki/Channel-Twitter) * Line @@ -59,7 +59,7 @@ In order to run the connector you need MongoDB and Redis installed and running. Clone the repository and install the dependencies ```sh -git clone https://github.com/RecastAI/bot-connector.git +git clone https://github.com/SAPConversationalAI/bot-connector.git cd bot-connector yarn install ``` @@ -80,7 +80,7 @@ yarn install You need to create a configuration file based on the following schema: -config/{env}.js +config/{env}.js (e.g. `config/development.js` for `NODE_ENV=development`) ``` module.exports = { @@ -120,7 +120,7 @@ yarn start:dev First of all, you need to create a connector with the Bot Connector's API. ```sh -curl -X POST 'http://localhost:8080/connectors' --data 'url=YOUR_CONNECTOR_ENDPOINT_URL' +curl -X POST 'http://localhost:8080/connectors' --data 'url=YOUR_BOT_ENDPOINT_URL' ``` Then you need some code so the Bot Connector, via the *connector* you've just created, can send you the messages it receives. You can use the code from the *example* as a starter. @@ -130,7 +130,7 @@ yarn install yarn start ``` -Now that your bot (well, your code) and the Bot Connector are running, you have to create channels. A channel is the actual link between your connector and a specific service like Messenger, Slack or Kik. One connector can have multiple channels. +Now that your bot (well, your code) and the Bot Connector are running, you have to create channels. A channel is the actual link between your bot (the connector) and a specific service like Messenger, Slack or Kik. One connector can have multiple channels. ## How it works @@ -142,11 +142,11 @@ This pipeline allows us to have an abstraction of messages independent of the pl #### Receive a message -The Bot Connector posts on your connector's endpoint each time a new message arrives from a channel. +The Bot Connector posts on the endpoint stored with the connector each time a new message arrives from a channel. * a new message is received by Bot Connector * the message is parsed by the corresponding service * the message is saved in MongoDB -* the message is post to the connector endpoint +* the message is post to the bot endpoint ![BotConnector-Receive](https://cdn.cai.tools.sap/bot-connector/flow-1.png) diff --git a/example/bot.js b/example/bot.js new file mode 100644 index 0000000..612dbaa --- /dev/null +++ b/example/bot.js @@ -0,0 +1,39 @@ +const express = require('express') +const bodyParser = require('body-parser') +const request = require('superagent') + +const app = express() +app.set('port', process.env.PORT || 5000) +app.use(bodyParser.json()) + +const config = { url: 'http://localhost:8080', connectorId: 'yourConnectorId' } + +/* Get the request from the connector */ + +app.post('/', (req, res) => { + // const conversationId = req.body.message.conversation + const messages = [{ + type: 'text', + content: 'my first message', + }] + + // send a response to the user + res.send(messages) +}) + +/* example for sending a message to the connector */ +// request.post(`${config.url}/connectors/${config.connectorId}/conversations/${conversationId}/messages`) +// .send({ messages, senderId: req.body.senderId }) +// .end((err, response) => { +// if (err) { +// console.error(err) +// res.status(500).send({ error: 'An internal error occured.' }) +// } else { +// console.log(response) +// res.send() +// } +// }) + +app.listen(app.get('port'), () => { + console.log('Our bot is running on port', app.get('port')) +}) diff --git a/example/package.json b/example/package.json new file mode 100644 index 0000000..972d753 --- /dev/null +++ b/example/package.json @@ -0,0 +1,14 @@ +{ + "name": "bot-example", + "version": "1.0.0", + "description": "", + "main": "bot.js", + "scripts": { + "start": "node bot.js" + }, + "dependencies": { + "body-parser": "^1.18.3", + "express": "^4.16.4", + "superagent": "^4.1.0" + } +} From bfa2146caa32c1c12cd8143367b4f5c33514579a Mon Sep 17 00:00:00 2001 From: Rene Springer Date: Fri, 10 May 2019 18:34:21 +0200 Subject: [PATCH 33/37] feat(*): add markdown support for some message types --- .../abstract_channel_integration.js | 25 ++++++++++ src/channel_integrations/facebook/channel.js | 5 ++ src/channel_integrations/microsoft/channel.js | 5 ++ src/channel_integrations/slack/channel.js | 5 ++ src/channel_integrations/telegram/channel.js | 10 +++- .../telegram/test/integration.js | 4 +- .../telegram/test/mockups.json | 6 +-- src/channel_integrations/webchat/channel.js | 4 ++ src/controllers/messages.js | 9 +++- src/models/message.js | 1 + src/utils/utils.js | 46 +++++++++++++++++++ 11 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/channel_integrations/abstract_channel_integration.js b/src/channel_integrations/abstract_channel_integration.js index f6150e2..4c034b7 100644 --- a/src/channel_integrations/abstract_channel_integration.js +++ b/src/channel_integrations/abstract_channel_integration.js @@ -203,6 +203,31 @@ export class AbstractChannelIntegration { */ formatOutgoingMessage (conversation, message, context) { return message } + /** + * Format Markdown the way it's supported by the external service + */ + formatMarkdown (message) { + const modifyTextLink = (result) => result.replace(/\[|\]/g, '') + const modifyEmptyLink = (result) => result.replace(/\(|\)|\[|\]/g, '') + const fixSingleStars = (result) => result.replace(/^\*|\*$/gm, '\'') + const fixSingleUnderscore = (result) => result.replace(/^_|_$/gm, '\'') + if (message.attachment.type === 'text') { + message.attachment.content = message.attachment.content + .replace(/_(.*?)_/gm, fixSingleUnderscore) + .replace(/\*(.*?)\*/gm, fixSingleStars) + .replace(/\[.+\]\(.*\)/gm, modifyTextLink) + .replace(/\[\]\(.*\)/g, modifyEmptyLink) + } + if (message.attachment.type === 'quickReplies') { + message.attachment.content.title = message.attachment.content.title + .replace(/_(.*?)_/gm, fixSingleUnderscore) + .replace(/\*(.*?)\*/gm, fixSingleStars) + .replace(/\[.+\]\(.*\)/gm, modifyTextLink) + .replace(/\[\]\(.*\)/g, modifyEmptyLink) + } + return message + } + /** * Sends response message to the channel. * @param {Conversation} conversation The message's conversation diff --git a/src/channel_integrations/facebook/channel.js b/src/channel_integrations/facebook/channel.js index 64af761..285dad1 100644 --- a/src/channel_integrations/facebook/channel.js +++ b/src/channel_integrations/facebook/channel.js @@ -22,6 +22,7 @@ import { } from './sdk' import { facebookCodesMap } from './constants' import { GetStartedButton, PersistentMenu } from '../../models' +import { formatMarkdownHelper } from '../../utils/utils' export default class Messenger extends AbstractChannelIntegration { @@ -199,6 +200,10 @@ export default class Messenger extends AbstractChannelIntegration { }) } + formatMarkdown (message) { + return formatMarkdownHelper(message, true) + } + formatOutgoingMessage (conversation, message, opts) { // https://developers.facebook.com/docs/messenger-platform/send-messages const { type, content } = _.get(message, 'attachment') diff --git a/src/channel_integrations/microsoft/channel.js b/src/channel_integrations/microsoft/channel.js index c396441..766859c 100644 --- a/src/channel_integrations/microsoft/channel.js +++ b/src/channel_integrations/microsoft/channel.js @@ -8,6 +8,7 @@ import AbstractChannelIntegration from '../abstract_channel_integration' import { logger } from '../../utils' import { BadRequestError, StopPipeline } from '../../utils/errors' import { microsoftParseMessage, microsoftGetBot, microsoftMakeAttachement } from './utils' +import { formatMarkdownHelper } from '../../utils/utils' const VIDEO_AS_LINK_HOSTS = [ 'youtube.com', @@ -77,6 +78,10 @@ export default class MicrosoftTemplate extends AbstractChannelIntegration { return msg } + formatMarkdown (message) { + return formatMarkdownHelper(message, false, false) + } + async formatOutgoingMessage (conversation, message, opts) { const { type, content } = _.get(message, 'attachment') const msg = new Message() diff --git a/src/channel_integrations/slack/channel.js b/src/channel_integrations/slack/channel.js index 16d4899..34e16cb 100644 --- a/src/channel_integrations/slack/channel.js +++ b/src/channel_integrations/slack/channel.js @@ -4,6 +4,7 @@ import request from 'superagent' import AbstractChannelIntegration from '../abstract_channel_integration' import { logger } from '../../utils' import { BadRequestError, UnauthorizedError } from '../../utils/errors' +import { formatMarkdownHelper } from '../../utils/utils' export default class Slack extends AbstractChannelIntegration { @@ -43,6 +44,10 @@ export default class Slack extends AbstractChannelIntegration { return msg } + formatMarkdown (message) { + return formatMarkdownHelper(message, true) + } + formatOutgoingMessage (conversation, message) { const type = _.get(message, 'attachment.type') const content = _.get(message, 'attachment.content') diff --git a/src/channel_integrations/telegram/channel.js b/src/channel_integrations/telegram/channel.js index b03ef05..42adbeb 100644 --- a/src/channel_integrations/telegram/channel.js +++ b/src/channel_integrations/telegram/channel.js @@ -10,6 +10,7 @@ import { StopPipeline, ValidationError, } from '../../utils/errors' +import { formatMarkdownHelper } from '../../utils/utils' const agent = superAgentPromise(superAgent, Promise) @@ -88,6 +89,10 @@ export default class Telegram extends AbstractChannelIntegration { } } + formatMarkdown (message) { + return formatMarkdownHelper(message) + } + formatOutgoingMessage ({ channel, chatId }, { attachment }, { senderId }) { const { type, content } = attachment const reply = { @@ -96,7 +101,6 @@ export default class Telegram extends AbstractChannelIntegration { to: senderId, token: _.get(channel, 'token'), } - switch (type) { case 'text': case 'video': @@ -109,7 +113,7 @@ export default class Telegram extends AbstractChannelIntegration { ...reply, type: 'card', photo: _.get(content, 'imageUrl'), - body: `*${_.get(content, 'title', '')}*\n**${_.get(content, 'subtitle', '')}**`, + body: `${_.get(content, 'title', '')}\n${_.get(content, 'subtitle', '')}`, keyboard: tgKeyboardLayout(content.buttons.map(tgFormatButton)), } case 'list': @@ -176,6 +180,7 @@ export default class Telegram extends AbstractChannelIntegration { chat_id: chatId, text: body, reply_markup: { keyboard, one_time_keyboard: true }, + parse_mode: 'Markdown', }) } catch (err) { this.logSendMessageError(err, type) @@ -239,6 +244,7 @@ export default class Telegram extends AbstractChannelIntegration { chat_id: chatId, [type]: body, reply_markup: { keyboard, one_time_keyboard: true }, + parse_mode: 'Markdown', }) } catch (err) { this.logSendMessageError(err, type, method) diff --git a/src/channel_integrations/telegram/test/integration.js b/src/channel_integrations/telegram/test/integration.js index 9be12d0..951ee7f 100644 --- a/src/channel_integrations/telegram/test/integration.js +++ b/src/channel_integrations/telegram/test/integration.js @@ -149,7 +149,7 @@ describe('Telegram channel', () => { }]], one_time_keyboard: true, }, - text: `*${cardElement.title}*\n**${cardElement.subtitle}**`, + text: `${cardElement.title}\n${cardElement.subtitle}`, }) const messageRequest = nock(telegramAPI) .post('/bottoken/sendMessage', expectedMessageBody).reply(200, {}) @@ -216,7 +216,7 @@ describe('Telegram channel', () => { }]], one_time_keyboard: true, }, - text: `*${buttonsElement.title}*\n****`, + text: `${buttonsElement.title}\n`, }) const messageRequest = nock(telegramAPI) .post('/bottoken/sendMessage', expectedMessageBody).reply(200, {}) diff --git a/src/channel_integrations/telegram/test/mockups.json b/src/channel_integrations/telegram/test/mockups.json index 974e8c6..0a29a99 100644 --- a/src/channel_integrations/telegram/test/mockups.json +++ b/src/channel_integrations/telegram/test/mockups.json @@ -21,7 +21,7 @@ "type": "card", "to": "", "token": "", - "body": "*Quick reply title*\n****", + "body": "Quick reply title\n", "keyboard": [ [ { @@ -84,7 +84,7 @@ "type": "card", "to": "", "token": "", - "body": "*Quick reply title*\n****", + "body": "Quick reply title\n", "keyboard": [ [ { @@ -202,4 +202,4 @@ "type": "carousel" } } -] \ No newline at end of file +] diff --git a/src/channel_integrations/webchat/channel.js b/src/channel_integrations/webchat/channel.js index 8803773..3541624 100644 --- a/src/channel_integrations/webchat/channel.js +++ b/src/channel_integrations/webchat/channel.js @@ -17,6 +17,10 @@ export default class Webchat extends AbstractChannelIntegration { } } + formatMarkdown (message) { + return message + } + formatOutgoingMessage (conversation, message) { return message } diff --git a/src/controllers/messages.js b/src/controllers/messages.js index fa88fcf..1bd0bc3 100644 --- a/src/controllers/messages.js +++ b/src/controllers/messages.js @@ -153,6 +153,9 @@ export default class MessagesController { if (message.delay) { newMessage.delay = message.delay } + if (message.markdown) { + newMessage.markdown = message.markdown + } await newMessage.save() return Promise.all([ @@ -203,8 +206,10 @@ export default class MessagesController { return Promise.resolve() } const channelIntegration = getChannelIntegrationByIdentifier(channelType) - message = await channelIntegration.formatOutgoingMessage(conversation, message, context) - return Promise.resolve([conversation, message, context, delay]) + let formattedMessage = message.markdown ? channelIntegration.formatMarkdown(message) : message + + formattedMessage = await channelIntegration.formatOutgoingMessage(conversation, formattedMessage, context) + return Promise.resolve([conversation, formattedMessage, context, delay]) } static async delayNextMessage (delay, conversation, channelIntegration, context) { diff --git a/src/models/message.js b/src/models/message.js index 8d92690..cdcd761 100644 --- a/src/models/message.js +++ b/src/models/message.js @@ -9,6 +9,7 @@ const MessageSchema = new mongoose.Schema({ isActive: { type: Boolean, required: true, default: true }, data: Object, delay: { type: Number }, + markdown: { type: Boolean }, receivedAt: { type: Date, default: Date.now }, }) diff --git a/src/utils/utils.js b/src/utils/utils.js index 94f8664..5434d4e 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -188,6 +188,52 @@ export function formatUserMessage (message) { } } +export function formatMarkdownHelper (message, linkMarkdown = false, boldItalicMarkdown = true) { + const applyRegex = (content) => { + const starsToHash = (result) => result.replace(/\*\*/gm, '#7Uk0I2smS') + const underscoresToHash = (result) => result.replace(/__/gm, '#7Uk0I2smS') + const modifySingleStars = (result) => result.replace(/^\*|\*$/gm, '_') + const modifyDoubleStars = (result) => result.replace(/^\*\*|\*\*([^*]|$)/gm, starsToHash) + const modifyDoubleUnderscores = (result) => result.replace(/^__|__([^_]|$)/gm, underscoresToHash) + const modifyTextLink = (result) => result.replace(/\[|\]/g, ' ') + const modifyEmptyLink = (result) => result.replace(/\(|\)|\[|\]/g, '') + const modifyStarsSpace = (result) => result.replace(/\*[\s]+|[\s]+\*/gm, '*') + const modifyUnderscoresSpace = (result) => result.replace(/_[\s]+|[\s]+_/gm, '_') + const modifyDoubleStarsSpace = (result) => result.replace(/\*\*[\s]+|[\s]+\*\*/gm, '**') + const modifyDoubleUnderscoresSpace = (result) => result.replace(/__[\s]+|[\s]+__/gm, '__') + let formattedContent = content + if (boldItalicMarkdown) { + formattedContent = formattedContent + .replace(/\*\*(.*?)\*\*([^*]|$)/gm, modifyDoubleStars) + .replace(/__(.*?)__([^_]|$)/gm, modifyDoubleUnderscores) + .replace(/\*(.*?)\*/gm, modifySingleStars) + .replace(/#7Uk0I2smS/gm, '*') + } + if (linkMarkdown) { + formattedContent = formattedContent + .replace(/\[.+\]\(.*\)/gm, modifyTextLink) + .replace(/\[\]\(.*\)/g, modifyEmptyLink) + } + return formattedContent + .replace(/\*(.*?)\*/gm, modifyStarsSpace) + .replace(/_(.*?)_/gm, modifyUnderscoresSpace) + .replace(/\*\*(.*?)\*\*/gm, modifyDoubleStarsSpace) + .replace(/__(.*?)__/gm, modifyDoubleUnderscoresSpace) + } + const type = _.get(message, 'attachment.type') + if (type === 'text') { + const content = _.get(message, 'attachment.content') + const regexedMessage = applyRegex(content) + message = _.set(message, 'attachment.content', regexedMessage) + } + if (type === 'quickReplies') { + const content = _.get(message, 'attachment.content.title') + const regexedMessage = applyRegex(content) + message = _.set(message, 'attachment.content.title', regexedMessage) + } + return message +} + _.mixin({ sortByKeys: (obj, comparator) => _(obj).toPairs() From 66ba37faaa4cd34275abee366cf20c896ebfe9ae Mon Sep 17 00:00:00 2001 From: Harinder-Singh-Batra <53439476+Harinder-Singh-Batra@users.noreply.github.com> Date: Mon, 5 Aug 2019 20:13:22 +0530 Subject: [PATCH 34/37] Project Sunset Notice --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 556b5ee..c2bf262 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ +# Project Sunset Notice + +This repository will be brought down / archived from github.com by **end of November, 2019** + +To enhance your overall bot building experience, we are working on implementing new features and changing the structure of [rich messages](https://cai.tools.sap/docs/concepts/structured-messages) supported by our platform. + +Unfortunately, this means that this repository will be unusable unless we go through a major refactor. + +Since all Bot Connector capabilities are integrated in our [bot building platform](https://cai.tools.sap), we’ve decided to sunset this open source repo. + +If you are running a standard / customised version of open source bot connector on your platform, **please migrate to the bot connector available on our bot building platform** (hosted in on SAP Cloud Platform), which offers integration with a wide number of channels which that we plan to make more robust. + +If you have any questions, please [contact our team](https://cai.tools.sap/contact). +## ![Bot Connector Logo](https://cdn.cai.tools.sap/bot-connector/bot-connector-logo.png) | [Supported Channels](#supported-channels) | [Getting Started](#getting-started) | [How it works](#how-it-works) | [Messages Formats](#messages-format) | [Getting Started with SAP Conversational AI]( #getting-started-with-sap-conversational-ai) | @@ -5,7 +19,7 @@ [💬 Questions / Comments? Join the discussion on our community Slack channel!](https://slack.cai.tools.sap) -# Bot Connector +## Bot Connector Bot Connector allows you to connect your bot to multiple messaging channels. From 7e75a773139b2d5d0d827e2abc8a419b092fa51d Mon Sep 17 00:00:00 2001 From: Harinder-Singh-Batra <53439476+Harinder-Singh-Batra@users.noreply.github.com> Date: Tue, 6 Aug 2019 21:18:12 +0530 Subject: [PATCH 35/37] Updated Readme Header --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c2bf262..90591cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Project Sunset Notice +# Sunset of Open Source Bot Connector -This repository will be brought down / archived from github.com by **end of November, 2019** +This repository will be brought down / archived from github.com by **end of November, 2019**. To enhance your overall bot building experience, we are working on implementing new features and changing the structure of [rich messages](https://cai.tools.sap/docs/concepts/structured-messages) supported by our platform. From 41ab6b6b67b03305fad3350af34242af06ca244d Mon Sep 17 00:00:00 2001 From: imailer2015 <39484626+imailer2015@users.noreply.github.com> Date: Tue, 19 Nov 2019 19:49:39 +0530 Subject: [PATCH 36/37] Readme updates for archiving repository --- README.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 90591cd..f37c1e8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,8 @@ -# Sunset of Open Source Bot Connector +# [Archieved] Open Source Bot Connector -This repository will be brought down / archived from github.com by **end of November, 2019**. +This repository has been archived as communicated earlier (August, 2019) and the source code is not maintained by SAP Conversational AI team. -To enhance your overall bot building experience, we are working on implementing new features and changing the structure of [rich messages](https://cai.tools.sap/docs/concepts/structured-messages) supported by our platform. - -Unfortunately, this means that this repository will be unusable unless we go through a major refactor. - -Since all Bot Connector capabilities are integrated in our [bot building platform](https://cai.tools.sap), we’ve decided to sunset this open source repo. +All Bot Connector capabilities are integrated in our [bot building platform](https://cai.tools.sap). If you are running a standard / customised version of open source bot connector on your platform, **please migrate to the bot connector available on our bot building platform** (hosted in on SAP Cloud Platform), which offers integration with a wide number of channels which that we plan to make more robust. From ad4126aa8ba492779967cbbf2a51a84a2d1b51ce Mon Sep 17 00:00:00 2001 From: imailer2015 <39484626+imailer2015@users.noreply.github.com> Date: Wed, 20 Nov 2019 14:19:20 +0530 Subject: [PATCH 37/37] [Fix] Spelling mistakes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f37c1e8..e4138ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# [Archieved] Open Source Bot Connector +# [Archived] Open Source Bot Connector -This repository has been archived as communicated earlier (August, 2019) and the source code is not maintained by SAP Conversational AI team. +As communicated earlier in August 2019, this repository has been archived and the source code is not maintained by the SAP Conversational AI team. All Bot Connector capabilities are integrated in our [bot building platform](https://cai.tools.sap).