├── .eslintrc.json ├── .gitignore ├── README.md ├── ReceiveSlackInteraction.js ├── SendKeenReportToSlack.js ├── SendToConsole.js ├── SendToHelpScout.js ├── SendToKeen.js ├── SendToSlack.js ├── SendToSlackInteractive.js ├── lib ├── discourse.js ├── helpscout.js ├── keen.js └── slack.js ├── package.json ├── scripts ├── deploy-webhooks.sh └── test-dev-webhooks.sh ├── test ├── DiscoursePostEvent.json ├── DiscourseTopicEvent.json ├── DiscourseUserEvent.json ├── SlackInteractionCommunity.json └── SlackInteractionDismiss.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "no-console": "off", 9 | "indent": [ 10 | "error", 11 | 2 12 | ], 13 | "linebreak-style": [ 14 | "error", 15 | "unix" 16 | ], 17 | "quotes": [ 18 | "error", 19 | "single" 20 | ], 21 | "semi": [ 22 | "error", 23 | "always" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .secrets.* 2 | .local.commands 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discourse-webhook-collector 2 | 3 | [Discourse](https://discourse.org) has [webhooks](https://meta.discourse.org/t/setting-up-webhooks/49045). These are very helpful for connecting Discourse to other things so you can [scale your community support](https://devrel.net/developer-experience/scale-community-support-apis). 4 | 5 | This repository contains a set of functions that catch Discourse webhooks, transform or enrich the JSON payload, and then call other downstream APIs and SaaS services. 6 | 7 | ![discourse-webhook-collector architecture](https://cl.ly/2Q0o3w3T0i3S/Screenshot%202017-06-11%2013.35.56.png) 8 | 9 | Support is currently included for [Slack](https://api.slack.com/), [Keen IO](https://keen.io/docs) and [HelpScout](http://developer.helpscout.net/help-desk-api/), but you can deploy as few or as many as you like. Connecting more services would not be hard - PR's are welcome! 10 | 11 | These webhooks are designed to run on the [webtask.io](https://webtask.io/docs/101) function-as-a-service platform from [Auth0](https://auth0.com). Webtask functions are written in JavaScript and can take advantage of packages on NPM. If you don't have previous webtask experience, I would recommend [taking a tutorial](https://auth0.com/blog/building-serverless-apps-with-webtask/) before working with this repository. 12 | 13 | ## Support 14 | 15 | There are a few moving parts here. If you run into any trouble getting up and running, the Algolia team is happy to lend a hand. Just send an email to [community@algolia.com](mailto:community@algolia.com). 16 | 17 | ## Prerequisites 18 | 19 | You will need admin access to running Discourse instance and [wt-cli](https://github.com/auth0/wt-cli) with an active webtask profile. Depending on which webhooks you want to use, you will need access to projects and API keys for the various supported services. 20 | 21 | Commands like `wt ls` and `wt create` should be working in your console before you begin. 22 | 23 | ## Getting Started 24 | 25 | The `SendToConsole` task simply logs a few Discourse-specific HTTP headers and the JSON body to the console. It's best to make sure you can run this successfully before connecting other downstream APIs. 26 | 27 | To do that, first clone this repo: 28 | 29 | ``` 30 | git clone git@github.com:algolia/discourse-webhook-collector.git 31 | ``` 32 | 33 | Install dependencies with yarn: 34 | 35 | ``` 36 | yarn 37 | ``` 38 | 39 | Create an empty `.secrets.development` file, as you do not need to specify any API keys just to log to the console. 40 | 41 | Deploy a function named `SendToConsoleDev` to webtask using the task shortcut in package.json. 42 | 43 | ``` 44 | yarn run dev-send-to-console 45 | ``` 46 | 47 | The shortcut is just a wrapper for this wt-cli command: 48 | 49 | ``` 50 | # you don't need to run this 51 | wt create --name SendToConsoleDev --bundle --no-parse --secrets-file .secrets.development --watch SendToConsole.js 52 | ``` 53 | 54 | Here's a breakdown of the command: 55 | 56 | - `--no-parse` is used to work around webtask HTTP payload size limits 57 | - `--bundle` allows us to put code in multiple files to make development cleaner 58 | - `--name` is the name of the webtask. It's very helpful two have two versions of each function deployed at the same time - one for live development and one for production. The development version webtask names are always suffixed with "Dev" and use `.secrets.development` instead of `.secrets.production` so you can specify different non-production downstream targets, like a Slack channel or Keen IO project that's used just for testing. 59 | - `--watch` keeps the process attached to the webtask, so you can see logs when it's invoked and so that any code changes you make are automatically uploaded (this is especially why you shouldn't use it for production). 60 | 61 | Now that our `SendToConsoleDev` webtask has been created, let's test it with cURL. In a new shell, export a `url` variable that points to your webtask profile domain: 62 | 63 | ``` 64 | export WEBTASK_URL=https://..webtask.io 65 | ``` 66 | 67 | Now, run a command shortcut that will send a cURL request to your new webtask using a JSON file in the `test` directory of this repository: 68 | 69 | ``` 70 | yarn run test-send-to-console 71 | ``` 72 | 73 | Which is just a shortcut for: 74 | 75 | ``` 76 | # you don't need to run this 77 | curl -X POST $WEBTASK_URL/SendToConsoleDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created' 78 | ``` 79 | 80 | You should see this output in the log of your webtask: 81 | 82 | ``` 83 | SendToConsole - Received webhook 84 | SendToConsole - Discourse event: topic_created 85 | SendToConsole - Discourse event type: topic 86 | SendToConsole - JSON payload: 87 | { 88 | "topic": { 89 | "id": 1608 90 | } 91 | } 92 | SendToConsole Success 93 | ``` 94 | 95 | If you do, that means that your webtask setup is working properly. Hooray! 96 | 97 | ## Connecting Discourse 98 | 99 | Now you're ready to add webhooks to your Discourse instance. In your Discourse admin UI, navigate to API, then webhooks, then click the button for "New webhook". 100 | 101 | Let's create a webhook that points to our `SendToConsoleDev` webtask. The Payload URL is the key field to populate, substituting the right values for your webtask domain: 102 | 103 | ![Discourse create webhook UI](https://cl.ly/1b3h371R2k06/Screenshot%202017-06-11%2012.27.57.png) 104 | 105 | Choose "Send me everything" from the event types section or select just the subset of events that you'd like to test with. Check the "Active" checkbox and then click "Create". Click "Go to events" and then click the "Ping" button. You should see the following in your webtask log: 106 | 107 | ``` 108 | SendToConsole - Received webhook 109 | SendToConsole - Discourse event: ping 110 | SendToConsole - Discourse event type: ping 111 | SendToConsole - JSON payload: 112 | { 113 | "ping": "OK" 114 | } 115 | SendToConsole Success 116 | ``` 117 | 118 | If you see this in the logs, your Discourse can successfully send events to your webtasks. Yay! Depending on what event types you chose, you will start seeing output when topics, posts and user events happen. 119 | 120 | ## Connecting APIs 121 | 122 | Just logging to the console isn't very interesting. Calling APIs would definitely be more interesting. 123 | 124 | ### Prerequisite - Discourse API access 125 | 126 | Discourse webhook JSON payloads do not always contain all of the information that we want and contents vary greatly based on the event type. That makes it difficult to keep event schema consistent for forwarding to analytics services like Keen IO. 127 | 128 | The solution that discourse-webhook-collector proposes is not to patch Discourse or use a plugin (at least not yet) but to use the Discourse API to fetch the missing JSON. Do this, discourse-webhook-collector needs to know the location of your Discourse instance and have an API key with admin access. 129 | 130 | To find or generate a Discourse API key, navigate to "API" and then "API" in the submenu that appears. 131 | 132 | Put the API key and the domain of your Discourse in your `.secrets.development` and `.secrets.production` files, like so: 133 | 134 | ``` 135 | DISCOURSE_URL=https:// 136 | DISCOURSE_API_KEY= 137 | ``` 138 | 139 | ### Slack 140 | 141 | Before beginning, create an [incoming webhook](https://api.slack.com/incoming-webhooks) using the Slack API pointing to the channel where you want Discourse activity to go to. Copy the webhook URL and add it to the secrets files. (You may want to use different values for `.secrets.development` and `.secrets.production` if you'd like to separate your testing from live messages.) 142 | 143 | ``` 144 | SEND_TO_SLACK_WEBHOOK_URL= 145 | ``` 146 | 147 | Next, deploy the development version of the `SendToSlack` webtask: 148 | 149 | ``` 150 | yarn run dev-send-to-slack 151 | ``` 152 | 153 | Next, add another webhook to your Discourse. Instead of pointing to the `SendToConsoleDev` path in the Payload URL field, use `SendToSlackDev` instead. 154 | 155 | Now, perform an action on your Discourse like creating a new topic. A new message should post to the Slack channel you configured in your incoming webhook. 156 | 157 | To deploy a production version (the only difference being that it uses the `.secrets.production` file instead), run the deploy command: 158 | 159 | ``` 160 | yarn run deploy-send-to-slack 161 | ``` 162 | 163 | After doing that, add a new webhook in your Discourse that points to `SendToSlack` in the payload URL field. You can leave the development webhook active or make it inactive, that's up to you. The main idea is to keep the production path separate so you can develop and test new webhook functionality without impacting production. 164 | 165 | ### Keen IO 166 | 167 | The instructions are essentially the same as for Slack. These are the values you'll need to put in the secrets files: 168 | 169 | ``` 170 | KEEN_PROJECT_ID= 171 | KEEN_WRITE_API_KEY= 172 | ``` 173 | 174 | Double-check to make sure you're using an API key that has write access. 175 | 176 | The commands to create the development and production versions of the webtask are as follows: 177 | 178 | ``` 179 | yarn run dev-send-to-keen 180 | yarn run deploy-send-to-keen 181 | ``` 182 | 183 | The corresponding webtask names to put in your Discourse Payload URL are `SendToKeenDev` and `SendToKeen`. 184 | 185 | If you want a head start on creating Keen IO dashboards for your Discourse activity, have a look at [vue-keen-dashboards](https://github.com/algolia/vue-keen-dashboards). Includes authentication and deploys easily to Netlify. 186 | 187 | ### HelpScout 188 | 189 | These are the secrets values that are required: 190 | 191 | ``` 192 | HELPSCOUT_MAILBOX_ID= 193 | HELPSCOUT_API_KEY= 194 | ``` 195 | 196 | See the [HelpScout API documentation](http://developer.helpscout.net/help-desk-api/) for information on how to provision API keys and get mailbox IDs. 197 | 198 | These are the commands to create the webtasks: 199 | 200 | ``` 201 | yarn run dev-send-to-helpscout 202 | yarn run deploy-send-to-helpscout 203 | ``` 204 | 205 | The corresponding webtask names to put in your Discourse Payload URL are `SendToHelpScoutDev` and `SendToHelpScout`. 206 | 207 | ### Contributing 208 | 209 | All contributions are welcome. If there is another API or service that you are using with Discourse, feel free to add it here using the current conventions and submit a PR. If you have a question, please open an issue. Thanks! 210 | -------------------------------------------------------------------------------- /ReceiveSlackInteraction.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var discourse = require('./lib/discourse'); 4 | var helpscout = require('./lib/helpscout'); 5 | var keen = require('./lib/keen'); 6 | 7 | module.exports = (context, cb) => { 8 | 9 | // these are the two helpscout mailboxes that map to the two buttons 10 | // you may have a different system and can adapt this code accordingly 11 | const COMMUNITY_MAILBOX = JSON.parse(context.secrets.COMMUNITY_MAILBOX); 12 | const SUPPORT_MAILBOX = JSON.parse(context.secrets.SUPPORT_MAILBOX); 13 | 14 | const payload = JSON.parse(context.data.payload); 15 | const callbackPayload = JSON.parse(payload.callback_id); 16 | 17 | const slackUsername = payload.user.name; 18 | const action = payload.actions[0]; 19 | 20 | if (action.name !== 'topic-created-options') { 21 | cb({ error: 'Unknown action!'}); 22 | return; 23 | } 24 | 25 | const mailbox = action.value === 'community' ? COMMUNITY_MAILBOX : SUPPORT_MAILBOX; 26 | const mailboxLink = `https://secure.helpscout.net/mailbox/${mailbox.slug}`; 27 | const shouldDispatch = action.value !== 'dismiss'; 28 | 29 | const attachment = Object.assign({}, payload.original_message.attachments[0]); 30 | delete attachment['footer_icon']; 31 | delete attachment['actions']; 32 | const response = { 33 | attachments: [attachment] 34 | }; 35 | 36 | if (shouldDispatch) { 37 | attachment.footer = `Dispatched to <${mailboxLink}|${mailbox.name}> mailbox by <@${slackUsername}>`; 38 | } else { 39 | attachment.footer = `Dismissed by <@${slackUsername}>`; 40 | } 41 | 42 | // return to slack immediately to avoid timeouts 43 | // todo: implement calling return_url if errors happen 44 | cb(null, response); 45 | 46 | var topic; 47 | return discourse.getDiscourseTopic(callbackPayload.topic.id, context).then((topicApiResponse) => { 48 | topic = topicApiResponse; 49 | const postId = topicApiResponse.post_stream.posts[0].id; 50 | return discourse.getDiscoursePost(postId, context).then((postApiResponse) => { 51 | return shouldDispatch 52 | ? (() => { 53 | 54 | var body = helpscout.toFormattedMessage(postApiResponse.cooked, attachment.title_link); 55 | return helpscout.createHelpscoutConversation({ 56 | mailbox: mailbox.id, 57 | email: callbackPayload.user.email, 58 | subject: topic.title, 59 | body: body, 60 | tags: ['discourse'].concat(topic.tags).concat(callbackPayload.category.slug ? callbackPayload.category.slug : []) 61 | }, context); 62 | 63 | })() 64 | : new Promise((resolve) => { 65 | attachment.footer = `Dismissed by <@${slackUsername}>`; 66 | resolve(); 67 | }); 68 | }); 69 | }).then(() => { 70 | 71 | return keen.recordKeenEvent('slack_button_clicked', { 72 | action: action, 73 | team: payload.team, 74 | channel: payload.channel, 75 | user: payload.user, 76 | context: { 77 | user: callbackPayload.user, 78 | topic: keen.topicForEvent(topic), 79 | category: callbackPayload.category 80 | } 81 | }, context); 82 | 83 | }).then(() => { 84 | 85 | console.log('ReceiveSlackInteraction Dispatch Success'); 86 | 87 | }).catch((error) => { 88 | 89 | console.log('ReceiveSlackInteraction Dispatch Failed', error); 90 | console.trace(error); 91 | cb(error); 92 | 93 | }); 94 | 95 | }; 96 | -------------------------------------------------------------------------------- /SendKeenReportToSlack.js: -------------------------------------------------------------------------------- 1 | var map = require('lodash.map'); 2 | var reduce = require('lodash.reduce'); 3 | var sortBy = require('lodash.sortBy'); 4 | 5 | var keen = require('./lib/keen'); 6 | var slack = require('./lib/slack'); 7 | var discourse = require('./lib/discourse'); 8 | 9 | module.exports = (context, cb) => { 10 | 11 | const queryOne = keen.runKeenQuery('count', { 12 | event_collection: 'discourse-topic_created', 13 | timeframe: 'last_24_hours' 14 | }, context); 15 | 16 | const queryTwo = keen.runKeenQuery('count', { 17 | event_collection: 'discourse-user_created', 18 | timeframe: 'last_24_hours' 19 | }, context); 20 | 21 | const queryThree = keen.runKeenQuery('count', { 22 | event_collection: 'discourse-post_created', 23 | timeframe: 'last_24_hours', 24 | group_by: 'user.username' 25 | }, context); 26 | 27 | const queryFour = keen.runKeenQuery('extraction', { 28 | event_collection: 'discourse-topic_created', 29 | timeframe: 'last_24_hours', 30 | property_names: 'topic.tags' 31 | }, context).then((response) => { 32 | let tags = map(response.result, 'topic.tags'); 33 | tags = reduce(tags, (memo, _tags) => { 34 | _tags.forEach((tag) => { 35 | if (memo[tag]) { 36 | memo[tag] = memo[tag] + 1; 37 | } else { 38 | memo[tag] = 1; 39 | } 40 | }); 41 | return memo; 42 | }, {}); 43 | tags = map(tags, (count, tag) => ({ ['topic.tag']: tag, result: count })); 44 | return tags; 45 | }); 46 | 47 | const queryFive = keen.runKeenQuery('count', { 48 | event_collection: 'discourse-post_created', 49 | timeframe: 'last_24_hours' 50 | }, context); 51 | 52 | Promise.all([queryOne, queryTwo, queryThree, queryFour, queryFive]).then((responses) => { 53 | 54 | const title = 'Daily Community Report'; 55 | 56 | slack.postSlackMessage({ 57 | attachments: [{ 58 | title: title, 59 | fallback: title, 60 | color: '#8E43E7', 61 | text: 'A summary of community activity from the last 24 hours.', 62 | fields: [{ 63 | title: 'New Topics', 64 | value: responses[0].result, 65 | short: true 66 | }, { 67 | title: 'New Posts', 68 | value: responses[4].result, 69 | short: true 70 | }, { 71 | title: 'New Users', 72 | value: responses[1].result, 73 | short: true 74 | }, { 75 | title: '', 76 | value: '', 77 | short: true 78 | }, { 79 | title: 'Top 10 Post Authors', 80 | value: map(sortBy(responses[2].result, 'result').reverse().slice(0, 10), (item) => `<${discourse.link(`users/${item['user.username']}`, context)}|${item['user.username']}>: ${item['result']}`).join('\n'), 81 | short: true 82 | }, { 83 | title: 'Top 10 Topic Tags', 84 | value: map(sortBy(responses[3], 'result').reverse().slice(0, 10), (item) => `<${discourse.link(`tags/${item['topic.tag']}`, context)}|${item['topic.tag']}>: ${item['result']}`).join('\n'), 85 | short: true 86 | }] 87 | }] 88 | }, context.secrets.ACTIVITY_REPORT_SLACK_WEBHOOK_URL).then(() => { 89 | 90 | cb(null, { ok: true }); 91 | 92 | }).catch((error) => { 93 | 94 | console.error('ActivityReport Failed', error); 95 | cb(error); 96 | 97 | }); 98 | 99 | }); 100 | 101 | }; 102 | -------------------------------------------------------------------------------- /SendToConsole.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var Express = require('express'); 4 | var Webtask = require('webtask-tools'); 5 | var bodyParser = require('body-parser'); 6 | 7 | var server = Express(); 8 | 9 | // use express to work around 1MB payload limit 10 | // make sure to use --no-parse when creating the webtask 11 | server.use(bodyParser.urlencoded({ extended: false })); 12 | server.use(bodyParser.json({ limit: '50mb' })); 13 | 14 | server.post('/', (req, res) => { 15 | 16 | const data = req.body; 17 | const context = req.webtaskContext; 18 | 19 | const discourseEventType = context.headers['x-discourse-event-type']; 20 | const discourseEvent = context.headers['x-discourse-event']; 21 | 22 | console.log('SendToConsole - Received webhook'); 23 | console.log(`SendToConsole - Discourse event: ${discourseEvent}`); 24 | console.log(`SendToConsole - Discourse event type: ${discourseEventType}`); 25 | console.log('SendToConsole - JSON payload:'); 26 | console.log(JSON.stringify(data, undefined, ' ')); 27 | 28 | console.log('SendToConsole Success'); 29 | res.json({ ok: true }); 30 | 31 | }); 32 | 33 | module.exports = Webtask.fromExpress(server); 34 | -------------------------------------------------------------------------------- /SendToHelpScout.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var Express = require('express'); 4 | var Webtask = require('webtask-tools'); 5 | var bodyParser = require('body-parser'); 6 | 7 | var discourse = require('./lib/discourse'); 8 | var helpscout = require('./lib/helpscout'); 9 | 10 | var server = Express(); 11 | 12 | // use express to work around 1MB payload limit 13 | // make sure to use --no-parse when creating the webtask 14 | server.use(bodyParser.urlencoded({ extended: false })); 15 | server.use(bodyParser.json({ limit: '50mb' })); 16 | 17 | server.post('/', (req, res) => { 18 | 19 | try { 20 | 21 | const data = req.body; 22 | const context = req.webtaskContext; 23 | 24 | const discourseEvent = context.headers['x-discourse-event']; 25 | 26 | let perEventPromise; 27 | 28 | // just implemented for topic_created right now 29 | if (discourseEvent === 'topic_created') { 30 | 31 | perEventPromise = discourse.getDiscourseTopic(data.topic.id, context).then((topicApiResponse) => { 32 | const actorUsername = topicApiResponse.details.created_by.username; 33 | return discourse.getDiscourseUser(actorUsername, context).then((userApiResponse) => { 34 | const postId = topicApiResponse.post_stream.posts[0].id; 35 | const categoryId = topicApiResponse.category_id; 36 | return discourse.getDiscoursePost(postId, context).then((postApiResponse) => { 37 | return discourse.getDiscourseCategory(categoryId, context).then((category) => { 38 | 39 | const topicLink = `${discourse.link(`t/${topicApiResponse.slug}/${topicApiResponse.id}`, context)}`; 40 | 41 | var body = helpscout.toFormattedMessage(postApiResponse.cooked, topicLink); 42 | return helpscout.createHelpscoutConversation({ 43 | mailbox: context.secrets.HELPSCOUT_MAILBOX_ID, 44 | email: userApiResponse.user.email, 45 | subject: topicApiResponse.title, 46 | body: body, 47 | tags: ['discourse', category.slug].concat(topicApiResponse.tags) 48 | }, context); 49 | 50 | }); 51 | }); 52 | }); 53 | }); 54 | 55 | } else { 56 | 57 | perEventPromise = Promise.resolve(); 58 | 59 | } 60 | 61 | return perEventPromise.then(() => { 62 | 63 | console.log('SendToHelpScout Success'); 64 | res.json({ ok: true }); 65 | 66 | }).catch((error) => { 67 | 68 | console.error('SendToHelpScout Failure', error); 69 | console.trace(error); 70 | res.status(500).send(error); 71 | 72 | }); 73 | 74 | } catch (error) { 75 | console.error('SendToHelpScout Error', error); 76 | console.trace(error); 77 | res.status(500).send(error); 78 | } 79 | 80 | }); 81 | 82 | module.exports = Webtask.fromExpress(server); 83 | -------------------------------------------------------------------------------- /SendToKeen.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var Express = require('express'); 4 | var Webtask = require('webtask-tools'); 5 | var bodyParser = require('body-parser'); 6 | 7 | var discourse = require('./lib/discourse'); 8 | var keen = require('./lib/keen'); 9 | 10 | var server = Express(); 11 | 12 | // use express to work around 1MB payload limit 13 | // make sure to use --no-parse when creating the webtask 14 | server.use(bodyParser.urlencoded({ extended: false })); 15 | server.use(bodyParser.json({ limit: '50mb' })); 16 | 17 | server.post('/', (req, res) => { 18 | 19 | try { 20 | 21 | const data = req.body; 22 | const context = req.webtaskContext; 23 | 24 | const discourseEventType = context.headers['x-discourse-event-type']; 25 | const discourseEvent = context.headers['x-discourse-event']; 26 | 27 | const keenCollectionName = `discourse-${discourseEvent}`; 28 | const keenEventBase = { 29 | discourse_event: discourseEvent, 30 | discourse_event_type: discourseEventType, 31 | }; 32 | 33 | // actor may or may not be correct, the webhook doesn't provide it 34 | // assumption now is that actor = creator of the object in question 35 | // which will at least be true for all create events 36 | 37 | let skip = false; 38 | let perEventPromise; 39 | 40 | if (discourseEventType === 'user') { 41 | const actorUsername = data.user.username; 42 | perEventPromise = discourse.getDiscourseUser(actorUsername, context).then((userApiResponse) => { 43 | Object.assign(keenEventBase, { 44 | user: keen.userForEvent(userApiResponse, context) 45 | }); 46 | }); 47 | } 48 | 49 | else if (discourseEventType === 'topic') { 50 | perEventPromise = discourse.getDiscourseTopic(data.topic.id, context).then((topicApiResponse) => { 51 | const actorUsername = topicApiResponse.details.created_by.username; 52 | const categoryId = topicApiResponse.category_id; 53 | return discourse.getDiscourseUser(actorUsername, context).then((userApiResponse) => { 54 | return discourse.getDiscourseCategory(categoryId, context).then((category) => { 55 | // don't send private or system topics to keen 56 | if (topicApiResponse.archetype === 'private_message') { 57 | skip = true; 58 | } 59 | Object.assign(keenEventBase, { 60 | user: keen.userForEvent(userApiResponse, context), 61 | topic: keen.topicForEvent(topicApiResponse), 62 | category: category ? keen.categoryForEvent(category) : {} 63 | }); 64 | }); 65 | }); 66 | }); 67 | } 68 | 69 | else if (discourseEventType === 'post') { 70 | perEventPromise = discourse.getDiscoursePost(data.post.id, context).then((postApiResponse) => { 71 | return discourse.getDiscourseTopic(postApiResponse.topic_id, context).then((topicApiResponse) => { 72 | const categoryId = topicApiResponse.category_id; 73 | const actorUsername = postApiResponse.username; 74 | return discourse.getDiscourseUser(actorUsername, context).then((userApiResponse) => { 75 | return discourse.getDiscourseCategory(categoryId, context).then((category) => { 76 | // don't send private or system topics to keen 77 | if (postApiResponse.username === 'system' || 78 | topicApiResponse.archetype === 'private_message') { 79 | skip = true; 80 | } 81 | Object.assign(keenEventBase, { 82 | user: keen.userForEvent(userApiResponse, context), 83 | post: keen.postForEvent(postApiResponse), 84 | topic: keen.topicForEvent(topicApiResponse), 85 | category: category ? keen.categoryForEvent(category) : {} 86 | }); 87 | }); 88 | }); 89 | }); 90 | }); 91 | 92 | } else { 93 | 94 | res.json({ ok: false, reason: `Unsupported event: ${discourseEvent}` }); 95 | return; 96 | 97 | } 98 | 99 | return perEventPromise.then(() => { 100 | 101 | if (!skip) { 102 | return keen.recordKeenEvent(keenCollectionName, keenEventBase, context); 103 | } 104 | 105 | }).then(() => { 106 | 107 | console.log(`SendToKeen Success, skipped=${skip}`); 108 | res.json({ ok: true, skipped: skip }); 109 | 110 | }).catch((error) => { 111 | 112 | console.error('SendToKeen Failure', error); 113 | console.trace(error); 114 | res.status(500).send(error); 115 | 116 | }); 117 | 118 | } catch (error) { 119 | console.error('SendToKeen Error', error); 120 | console.trace(error); 121 | res.status(500).send(error); 122 | } 123 | 124 | }); 125 | 126 | module.exports = Webtask.fromExpress(server); 127 | -------------------------------------------------------------------------------- /SendToSlack.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var Express = require('express'); 4 | var Webtask = require('webtask-tools'); 5 | var bodyParser = require('body-parser'); 6 | 7 | var discourse = require('./lib/discourse'); 8 | var slack = require('./lib/slack'); 9 | var find = require('lodash.find'); 10 | 11 | var server = Express(); 12 | 13 | // use express to work around 1MB payload limit 14 | // make sure to use --no-parse when creating the webtask 15 | server.use(bodyParser.urlencoded({ extended: false })); 16 | server.use(bodyParser.json({ limit: '50mb' })); 17 | 18 | server.post('/', (req, res) => { 19 | 20 | try { 21 | 22 | const data = req.body; 23 | const context = req.webtaskContext; 24 | 25 | const discourseEventType = context.headers['x-discourse-event-type']; 26 | const discourseEvent = context.headers['x-discourse-event']; 27 | 28 | let skipped = false; 29 | let perEventPromise; 30 | 31 | function filterUserOut(user) { 32 | // if you set DISCOURSE_FILTER_GROUP_ID in your secrets, only 33 | // activity from that group will be sent to the slack channel 34 | let discourseGroupFilter = context.secrets.DISCOURSE_FILTER_GROUP_ID; 35 | return discourseGroupFilter && 36 | typeof find(user.groups, { id: parseInt(discourseGroupFilter) }) !== 'object'; 37 | } 38 | 39 | if (discourseEventType === 'user') { 40 | const actorUsername = data.user.username; 41 | perEventPromise = discourse.getDiscourseUserWithAllGroups(actorUsername, context).then((userApiResponse) => { 42 | 43 | if (filterUserOut(userApiResponse.user)) { 44 | skipped = true; 45 | return; 46 | } 47 | 48 | const user = userApiResponse.user; 49 | const title = ''; 50 | 51 | const attachment = slack.getSimpleSlackAttachment(user, title, '', '', context); 52 | attachment.color = '#3369E7'; 53 | attachment.footer = `Discourse: ${discourseEvent}`; 54 | slack.addCustomerField(attachment, user); 55 | 56 | return slack.postSlackMessage({ 57 | attachments: [attachment] 58 | }, context.secrets.SEND_TO_SLACK_WEBHOOK_URL); 59 | 60 | }); 61 | } 62 | 63 | if (discourseEventType === 'topic') { 64 | perEventPromise = discourse.getDiscourseTopic(data.topic.id, context).then((topicApiResponse) => { 65 | const actorUsername = topicApiResponse.details.created_by.username; 66 | const categoryId = topicApiResponse.category_id; 67 | return discourse.getDiscourseUserWithAllGroups(actorUsername, context).then((userApiResponse) => { 68 | return discourse.getDiscourseCategory(categoryId, context).then((category) => { 69 | 70 | // don't send private messages to slack 71 | if (topicApiResponse.archetype === 'private_message') { 72 | skipped = true; 73 | return; 74 | } 75 | 76 | if (filterUserOut(userApiResponse.user)) { 77 | skipped = true; 78 | return; 79 | } 80 | 81 | const topic = topicApiResponse; 82 | const user = userApiResponse.user; 83 | 84 | const title = category ? `[${category.name}] ${topic.title}` : topic.title; 85 | const titleLink = discourse.link(`t/${topic.slug}/${topic.id}`, context); 86 | 87 | const attachment = slack.getSimpleSlackAttachment(user, title, titleLink, '', context); 88 | slack.addCustomerField(attachment, user); 89 | slack.addTagsField(attachment, topic, context); 90 | attachment.color = category ? category.color : ''; 91 | attachment.footer = `Discourse: ${discourseEvent}`; 92 | 93 | return slack.postSlackMessage({ 94 | attachments: [attachment] 95 | }, context.secrets.SEND_TO_SLACK_WEBHOOK_URL); 96 | 97 | }); 98 | }); 99 | }); 100 | } 101 | 102 | if (discourseEventType === 'post') { 103 | perEventPromise = discourse.getDiscoursePost(data.post.id, context).then((postApiResponse) => { 104 | return discourse.getDiscourseTopic(postApiResponse.topic_id, context).then((topicApiResponse) => { 105 | const categoryId = topicApiResponse.category_id; 106 | const actorUsername = postApiResponse.username; 107 | return discourse.getDiscourseUserWithAllGroups(actorUsername, context).then((userApiResponse) => { 108 | return discourse.getDiscourseCategory(categoryId, context).then((category) => { 109 | 110 | // don't send private messages or system posts to slack 111 | if (topicApiResponse.archetype === 'private_message' || postApiResponse.username === 'system') { 112 | skipped = true; 113 | return; 114 | } 115 | 116 | if (filterUserOut(userApiResponse.user)) { 117 | skipped = true; 118 | return; 119 | } 120 | 121 | const topic = topicApiResponse; 122 | const user = userApiResponse.user; 123 | 124 | const title = category ? `[${category.name}] ${topic.title}` : topic.title; 125 | const titleLink = discourse.link(`t/${topic.slug}/${topic.id}/${postApiResponse.post_number}`, context); 126 | 127 | const attachment = slack.getSimpleSlackAttachment(user, title, titleLink, postApiResponse.raw, context); 128 | slack.addCustomerField(attachment, user); 129 | slack.addTagsField(attachment, topicApiResponse, context); 130 | attachment.color = category ? category.color : ''; 131 | attachment.footer = `Discourse: ${discourseEvent}`; 132 | 133 | return slack.postSlackMessage({ 134 | attachments: [attachment] 135 | }, context.secrets.SEND_TO_SLACK_WEBHOOK_URL); 136 | 137 | }); 138 | }); 139 | }); 140 | }); 141 | } 142 | 143 | return perEventPromise.then(() => { 144 | 145 | console.log(skipped ? 'SendToSlack Skipped' : 'SendToSlack Success'); 146 | res.json({ ok: true, skipped }); 147 | 148 | }).catch((error) => { 149 | 150 | console.error('SendToSlack Failure', error); 151 | console.trace(error); 152 | res.status(500).send(error); 153 | 154 | }); 155 | 156 | } catch (error) { 157 | console.error('SendToSlack Error', error); 158 | console.trace(error); 159 | res.status(500).send(error); 160 | } 161 | 162 | }); 163 | 164 | module.exports = Webtask.fromExpress(server); 165 | -------------------------------------------------------------------------------- /SendToSlackInteractive.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var Express = require('express'); 4 | var Webtask = require('webtask-tools'); 5 | var bodyParser = require('body-parser'); 6 | var map = require('lodash.map'); 7 | var pick = require('lodash.pick'); 8 | 9 | var discourse = require('./lib/discourse'); 10 | var slack = require('./lib/slack'); 11 | 12 | var server = Express(); 13 | 14 | // use express to work around 1MB payload limit 15 | // make sure to use --no-parse when creating the webtask 16 | server.use(bodyParser.urlencoded({ extended: false })); 17 | server.use(bodyParser.json({ limit: '50mb' })); 18 | 19 | server.post('/', (req, res) => { 20 | 21 | try { 22 | 23 | const data = req.body; 24 | const context = req.webtaskContext; 25 | 26 | const discourseEvent = context.headers['x-discourse-event']; 27 | 28 | let skipped = false; 29 | let perEventPromise; 30 | 31 | // only supported for topic_created 32 | if (discourseEvent === 'topic_created') { 33 | 34 | perEventPromise = discourse.getDiscourseTopic(data.topic.id, context).then((topicApiResponse) => { 35 | const categoryId = topicApiResponse.category_id; 36 | const postId = topicApiResponse.post_stream.posts[0].id; 37 | return discourse.getDiscoursePost(postId, context).then((postApiResponse) => { 38 | const actorUsername = postApiResponse.username; 39 | return discourse.getDiscourseUser(actorUsername, context).then((userApiResponse) => { 40 | return discourse.getDiscourseCategory(categoryId, context).then((category) => { 41 | 42 | // don't send private messages or system posts to slack 43 | if (topicApiResponse.archetype === 'private_message' || postApiResponse.username === 'system') { 44 | skipped = true; 45 | return; 46 | } 47 | 48 | const topic = topicApiResponse; 49 | const user = userApiResponse.user; 50 | 51 | const title = category ? `[${category.name}] ${topic.title}` : topic.title; 52 | const topicLink = `${discourse.link(`t/${topic.slug}/${topic.id}`, context)}`; 53 | const tagsLink = map(topic.tags, (tag) => (`<${discourse.link(`tags/${tag}`, context)}|${tag}>`)).join(', '); 54 | 55 | const helpscoutSearchURL = `https://secure.helpscout.net/search/?query=${user.email}`; 56 | const thumbUrl = `${discourse.link(user.avatar_template.replace('{size}', '64'), context)}`; 57 | 58 | const callbackId = JSON.stringify({ 59 | topic: pick(topic, ['id']), 60 | category: category ? pick(category, ['id', 'name', 'slug']) : {}, 61 | user: pick(user, ['id', 'name', 'username', 'email']) 62 | }); 63 | 64 | var attachment = { 65 | fallback: title, 66 | title: title, 67 | title_link: topicLink, 68 | color: category ? category.color : '', 69 | callback_id: callbackId, 70 | author_name: `${user.name} @${user.username}`, 71 | author_link: discourse.link(`users/${user.username}/summary`, context), 72 | text: postApiResponse.raw, 73 | footer: 'Discourse: topic_created', 74 | ts: Math.floor(Date.parse(topic.created_at) / 1000), 75 | thumb_url: thumbUrl, 76 | fields: [ 77 | { 78 | title: 'Customer', 79 | value: `<${helpscoutSearchURL}|${user.email}>`, 80 | short: true 81 | }, 82 | { 83 | title: 'Tags', 84 | value: tagsLink, 85 | short: true 86 | }, 87 | ], 88 | actions: [ 89 | { 90 | 'type': 'button', 91 | 'name': 'topic-created-options', 92 | 'text': 'Dismiss', 93 | 'value': 'dismiss' 94 | }, { 95 | 'type': 'button', 96 | 'name': 'topic-created-options', 97 | 'text': 'Support', 98 | 'value': 'support' 99 | }, { 100 | 'type': 'button', 101 | 'name': 'topic-created-options', 102 | 'text': 'Community', 103 | 'value': 'community' 104 | } 105 | ] 106 | }; 107 | 108 | return slack.postSlackMessage({ 109 | attachments: [attachment] 110 | }, context.secrets.SEND_TO_SLACK_INTERACTIVE_WEBHOOK_URL); 111 | 112 | }); 113 | }); 114 | }); 115 | }); 116 | 117 | return perEventPromise.then(() => { 118 | 119 | console.log(skipped ? 'SendToSlackInteractive Skipped' : 'SendToSlackInteractive Success'); 120 | res.json({ ok: true, skipped }); 121 | 122 | }).catch((error) => { 123 | 124 | console.error('SendToSlackInteractive Failure', error); 125 | console.trace(error); 126 | res.status(500).send(error); 127 | 128 | }); 129 | 130 | } else { 131 | 132 | console.log('SendToSlackInteractive IgnoredEvent'); 133 | res.json({ ok: true, ignored: true, skipped: true }); 134 | 135 | } 136 | 137 | } catch (error) { 138 | console.error('SendToSlackInteractive Error', error); 139 | console.trace(error); 140 | res.status(500).send(error); 141 | } 142 | 143 | }); 144 | 145 | module.exports = Webtask.fromExpress(server); 146 | -------------------------------------------------------------------------------- /lib/discourse.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var find = require('lodash.find'); 4 | var request = require('request'); 5 | 6 | module.exports = { 7 | 8 | link: (path, context) => (`${context.secrets.DISCOURSE_URL}/${path}`), 9 | 10 | getDiscourseJSON: (resource, discourseApiUsername, context) => { 11 | var url = `${context.secrets.DISCOURSE_URL}/${resource}.json?api_username=${discourseApiUsername}&api_key=${context.secrets.DISCOURSE_API_KEY}`; 12 | return new Promise((resolve, reject) => { 13 | request({ 14 | method: 'GET', url, json: true 15 | }, function(error, res, body) { 16 | if (error || body.errors) { 17 | console.error('DiscourseAPI Error', error || body.errors); 18 | reject(error || body.errors); 19 | } else { 20 | resolve(body); 21 | } 22 | }); 23 | }); 24 | }, 25 | 26 | getDiscourseUser: function(username, context) { 27 | return this.getDiscourseJSON(`users/${username}`, username, context); 28 | }, 29 | 30 | // calling with api_username set to the username in question returns 31 | // fields like their email that you don't get with calling as "system" 32 | // but to get some things, groups they are in that are not shown to them, 33 | // you need to call as system 34 | getDiscourseUserWithAllGroups: function(username, context) { 35 | return this.getDiscourseJSON(`users/${username}`, 'system', context).then((userBySystem) => { 36 | return this.getDiscourseJSON(`users/${username}`, username, context).then((userByUsername) => { 37 | // copy the groups over from system 38 | userByUsername.user.groups = userBySystem.user.groups; 39 | return userByUsername; 40 | }); 41 | }); 42 | }, 43 | 44 | getDiscourseTopic: function(topic_id, context) { 45 | return this.getDiscourseJSON(`t/${topic_id}`, 'system', context); 46 | }, 47 | 48 | getDiscoursePost: function(post_id, context) { 49 | return this.getDiscourseJSON(`posts/${post_id}`, 'system', context); 50 | }, 51 | 52 | getDiscourseCategory: function(categoryId, context) { 53 | return this.getDiscourseJSON('categories', 'system', context).then((body) => { 54 | var categories = body.category_list.categories; 55 | return find(categories, { id: categoryId }); 56 | }); 57 | } 58 | 59 | }; 60 | -------------------------------------------------------------------------------- /lib/helpscout.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var request = require('request'); 4 | 5 | module.exports = { 6 | 7 | toFormattedMessage: (html, link) => { 8 | var body = ''; 9 | body += html; 10 | body += '-----------------------------------------------------------
'; 11 | body += '

'; 12 | body += '👉 PLEASE ANSWER ON DISCOURSE THEN CLOSE THIS TICKET 👈'; 13 | body += '

'; 14 | body += `${link}`; 15 | body += '
'; 16 | body += '-----------------------------------------------------------'; 17 | return body; 18 | }, 19 | 20 | createHelpscoutConversation: ({ email, subject, body, tags, mailbox }, context) => { 21 | return new Promise((resolve, reject) => { 22 | request.post({ 23 | auth: { user: context.secrets.HELPSCOUT_API_KEY, pass: 'X' }, 24 | url: 'https://api.helpscout.net/v1/conversations.json', 25 | json: true, 26 | body: { 27 | type: 'email', 28 | customer: { 29 | email: email 30 | }, 31 | subject: subject, 32 | tags: tags, 33 | mailbox: { 34 | id: mailbox 35 | }, 36 | status: 'active', 37 | threads: [{ 38 | type: 'customer', 39 | createdBy: { 40 | email: email, 41 | type: 'customer' 42 | }, 43 | body: body, 44 | status: 'active' 45 | }] 46 | } 47 | }, (error, res, body) => { 48 | if (error) { 49 | console.error('CreateHelpScoutConversation Error', error); 50 | console.trace(error); 51 | reject(error); 52 | } else if (res.statusCode > 299) { 53 | console.error('CreateHelpScoutConversation Error'); 54 | reject(res.body); 55 | } else { 56 | console.log('CreateHelpScoutConversation Success'); 57 | resolve(body); 58 | } 59 | }); 60 | }); 61 | } 62 | 63 | }; 64 | -------------------------------------------------------------------------------- /lib/keen.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var pick = require('lodash.pick'); 4 | var map = require('lodash.map'); 5 | var find = require('lodash.find'); 6 | 7 | var KeenAnalysis = require('keen-analysis'); 8 | var KeenTracking = require('keen-tracking'); 9 | 10 | module.exports = { 11 | 12 | recordKeenEvent: (eventCollection, eventBody, context) => { 13 | const keenProjectId = context.secrets.KEEN_PROJECT_ID; 14 | const keenWriteKey = context.secrets.KEEN_WRITE_API_KEY; 15 | var keenClient = new KeenTracking({ 16 | projectId: keenProjectId, writeKey: keenWriteKey 17 | }); 18 | return new Promise((resolve, reject) => { 19 | if (!eventBody.keen) 20 | eventBody.keen = {}; 21 | Object.assign(eventBody.keen, { 22 | addons: [{ 23 | name: 'keen:date_time_parser', 24 | input: { 25 | date_time: 'keen.timestamp', 26 | }, 27 | 'output': 'timestamp_info' 28 | }] 29 | }); 30 | keenClient.recordEvent(eventCollection, eventBody, function(error, res) { 31 | if (error) { 32 | console.error('KeenAPI Error', error); 33 | reject(error); 34 | } else { 35 | console.error('KeenAPI Success'); 36 | resolve(res); 37 | } 38 | }); 39 | }); 40 | }, 41 | 42 | runKeenQuery: (operation, properties, context) => { 43 | const keenProjectId = context.secrets.KEEN_PROJECT_ID; 44 | const keenReadKey = context.secrets.KEEN_READ_API_KEY; 45 | var keenClient = new KeenAnalysis({ 46 | projectId: keenProjectId, readKey: keenReadKey 47 | }); 48 | return keenClient.query(operation, properties); 49 | }, 50 | 51 | userForEvent: (apiResponse, context) => { 52 | let user = apiResponse.user; 53 | let baseUser = Object.assign( 54 | pick(user, [ 55 | 'id', 'name', 'email', 'avatar_template', 'username', 'created_at', 56 | 'updated_at', 'last_seen_at', 'last_posted_at', 'website', 'website_name', 57 | 'bio_raw', 'location', 'trust_level', 'moderator', 'admin', 'title', 58 | 'badge_count', 'post_count', 'profile_view_count', 'card_image_badge', 59 | 'card_image_badge_id', 'featured_user_badge_ids' 60 | ]), { 61 | badge_ids: map(apiResponse.badges, 'id'), 62 | badge_names: map(apiResponse.badges, 'name'), 63 | badge_descriptions: map(apiResponse.badges, 'description'), 64 | badge_icons: map(apiResponse.badges, 'icon'), 65 | badge_images: map(apiResponse.badges, 'image'), 66 | badge_grant_counts: map(apiResponse.badges, 'grant_count'), 67 | badge_badge_type_ids: map(apiResponse.badges, 'badge_type_id'), 68 | badge_badge_grouping_ids: map(apiResponse.badges, 'badge_grouping_id'), 69 | group_ids: map(user.groups, 'id') 70 | } 71 | ); 72 | // put these values in your secrets file if you'd like to 73 | // have specific flags for activity by certain groups 74 | if (context.secrets.EMPLOYEES_DISCOURSE_GROUP_ID) { 75 | baseUser.is_employee = typeof find(user.groups, { id: parseInt(context.secrets.EMPLOYEES_DISCOURSE_GROUP_ID) }) === 'object'; 76 | } 77 | if (context.secrets.AMBASSADORS_DISCOURSE_GROUP_ID) { 78 | baseUser.is_ambassador = typeof find(user.groups, { id: parseInt(context.secrets.AMBASSADORS_DISCOURSE_GROUP_ID) }) === 'object'; 79 | } 80 | return baseUser; 81 | }, 82 | 83 | topicForEvent: (topic) => { 84 | return Object.assign( 85 | pick(topic, [ 86 | 'id', 'title', 'fancy_title', 'posts_count', 'created_at', 87 | 'views', 'reply_count', 'participant_count', 'like_count', 'last_posted_at', 88 | 'visible', 'closed', 'archived', 'has_summary', 'archetype', 89 | 'slug', 'category_id', 'word_count', 'deleted_at', 'user_id', 90 | 'pinned_globally', 'pinned', 'created_by', 'last_poster', 'highest_post_number', 91 | 'deleted_by', 'has_deleted', 'tags', 'accepted_answer' 92 | ]), { 93 | post_ids: (typeof topic.post_stream === 'object') ? map(topic.post_stream.posts, 'id') : undefined, 94 | participant_ids: map(topic.details.participants, 'id'), 95 | participant_usernames: map(topic.details.participants, 'username'), 96 | participant_avatar_templates: map(topic.details.participants, 'avatar_template') 97 | } 98 | ); 99 | }, 100 | 101 | postForEvent: (post) => { 102 | return pick(post, [ 103 | 'id', 'name', 'username', 'avatar_template', 'created_at', 104 | 'cooked', 'raw', 'post_number', 'post_type', 'updated_at', 'avg_time', 105 | 'reply_count', 'reads', 'score', 'topic_id', 'topic_slug', 106 | 'version', 'user_id', 'deleted_at', 'moderator', 'admin', 107 | 'accepted_answer', 'hidden', 'wiki', 'user_deleted', 'trust_level' 108 | ]); 109 | }, 110 | 111 | categoryForEvent: (category) => { 112 | return pick(category, [ 113 | 'id', 'name', 'color', 'text_color', 'slug', 114 | 'topic_count', 'post_count', 'position', 'description', 115 | 'description_text', 'topic_url', 'topics_day', 'topics_week', 116 | 'topics_month', 'topics_year', 'topics_all_time', 'read_restricted' 117 | ]); 118 | } 119 | 120 | }; 121 | -------------------------------------------------------------------------------- /lib/slack.js: -------------------------------------------------------------------------------- 1 | 'use latest'; 2 | 3 | var map = require('lodash.map'); 4 | var request = require('request'); 5 | 6 | var discourse = require('./discourse'); 7 | 8 | module.exports = { 9 | 10 | postSlackMessage: (body, slackWebhookUrl) => { 11 | return new Promise((resolve, reject) => { 12 | request({ 13 | method: 'POST', 14 | url: slackWebhookUrl, 15 | json: true, 16 | body: body, 17 | }, function(error, res, body) { 18 | if (error) { 19 | console.error('PostSlackMessage Error', error); 20 | console.trace(error); 21 | reject(error); 22 | } else { 23 | console.error('PostSlackMessage Success'); 24 | resolve(body); 25 | } 26 | }); 27 | }); 28 | }, 29 | 30 | getSlackAttachmentFields: function(user, context) { 31 | return { 32 | helpscoutSearchURL: `https://secure.helpscout.net/search/?query=${user.email}`, 33 | thumb_url: `${this.link(user.avatar_template.replace('{size}', '64'), context)}`, 34 | }; 35 | }, 36 | 37 | getSimpleSlackAttachment: function(user, title, titleLink, text, context) { 38 | return { 39 | fallback: title, 40 | title: title, 41 | title_link: titleLink, 42 | text: text, 43 | author_name: `${user.name} @${user.username}`, 44 | author_link: discourse.link(`users/${user.username}/summary`, context), 45 | thumb_url: `${discourse.link(user.avatar_template.replace('{size}', '64'), context)}`, 46 | ts: new Date().getTime() / 1000, 47 | fields: [] 48 | }; 49 | }, 50 | 51 | addTagsField: function (attachment, topic, context) { 52 | const tagsLink = map(topic.tags, (tag) => (`<${discourse.link(`tags/${tag}`, context)}|${tag}>`)).join(', '); 53 | attachment.fields.push({ 54 | title: 'Tags', 55 | value: tagsLink, 56 | short: true 57 | }); 58 | }, 59 | 60 | addCustomerField: function (attachment, user) { 61 | // if you're not using helpscout, you might create a different link 62 | // based on the data in the Discourse user JSON 63 | const customerLink = ``; 64 | attachment.fields.push({ 65 | title: 'Customer', 66 | value: customerLink, 67 | short: true 68 | }); 69 | } 70 | 71 | }; 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev-send-to-console": "wt create --profile $WEBTASK_PROFILE --name SendToConsoleDev --bundle --no-parse --secrets-file .secrets.development --watch SendToConsole.js", 4 | "test-send-to-console": "curl -X POST $WEBTASK_URL/SendToConsoleDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created'", 5 | "dev-send-to-keen": "wt create --profile $WEBTASK_PROFILE --name SendToKeenDev --bundle --no-parse --secrets-file .secrets.development --watch SendToKeen.js", 6 | "deploy-send-to-keen": "wt create --profile $WEBTASK_PROFILE --name SendToKeen --bundle --no-parse --secrets-file .secrets.production SendToKeen.js", 7 | "dev-send-to-slack": "wt create --profile $WEBTASK_PROFILE --name SendToSlackDev --bundle --no-parse --secrets-file .secrets.development --watch SendToSlack.js", 8 | "deploy-send-to-slack": "wt create --profile $WEBTASK_PROFILE --name SendToSlack --bundle --no-parse --secrets-file .secrets.production SendToSlack.js", 9 | "dev-send-to-slack-interactive": "wt create --profile $WEBTASK_PROFILE --name SendToSlackInteractiveDev --bundle --no-parse --secrets-file .secrets.development --watch SendToSlackInteractive.js", 10 | "deploy-send-to-slack-interactive": "wt create --profile $WEBTASK_PROFILE --name SendToSlackInteractive --bundle --no-parse --secrets-file .secrets.production SendToSlackInteractive.js", 11 | "dev-receive-slack-interaction": "wt create --profile $WEBTASK_PROFILE --name ReceiveSlackInteractionDev --bundle --secrets-file .secrets.development --watch ReceiveSlackInteraction.js", 12 | "deploy-receive-slack-interaction": "wt create --profile $WEBTASK_PROFILE --name ReceiveSlackInteraction --bundle --secrets-file .secrets.production ReceiveSlackInteraction.js", 13 | "dev-send-keen-report-to-slack": "wt create --profile $WEBTASK_PROFILE --name SendKeenReportToSlackDev --bundle --secrets-file .secrets.development --watch SendKeenReportToSlack.js", 14 | "schedule-send-keen-report-to-slack-dev": "wt cron schedule --profile $WEBTASK_PROFILE --name SendKeenReportToSlack --bundle --secrets-file .secrets.development \"0 8 * * *\" SendKeenReportToSlack.js", 15 | "dev-send-to-helpscout": "wt create --profile $WEBTASK_PROFILE --name SendToHelpScoutDev --bundle --no-parse --secrets-file .secrets.development --watch SendToHelpScout.js", 16 | "deploy-send-to-helpscout": "wt create --profile $WEBTASK_PROFILE --name SendToHelpScout --bundle --no-parse --secrets-file .secrets.production SendToHelpScout.js" 17 | }, 18 | "dependencies": { 19 | "body-parser": "^1.17.1", 20 | "express": "4.15.x", 21 | "keen-analysis": "^1.2.2", 22 | "keen-tracking": "^1.1.3", 23 | "lodash.find": "^4.6.0", 24 | "lodash.map": "^4.6.0", 25 | "lodash.pick": "^4.4.0", 26 | "lodash.reduce": "^4.6.0", 27 | "lodash.sortby": "^4.7.0", 28 | "request": "^2.81.0", 29 | "webtask-tools": "^3.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /scripts/deploy-webhooks.sh: -------------------------------------------------------------------------------- 1 | # export a $WEBTASK_PROFILE variable in your shell containing your webtask profile name 2 | 3 | yarn deploy-send-to-keen 4 | yarn deploy-send-to-slack 5 | yarn deploy-receive-slack-interaction 6 | yarn deploy-send-to-slack-interactive 7 | -------------------------------------------------------------------------------- /scripts/test-dev-webhooks.sh: -------------------------------------------------------------------------------- 1 | # export a $WEBTASK_URL variable in your shell containing your *.*.webtask.io domain 2 | 3 | echo 'topic - console' 4 | curl -X POST $WEBTASK_URL/SendToConsoleDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created' 5 | 6 | echo '' 7 | echo 'user - slack' 8 | curl -X POST $WEBTASK_URL/SendToSlackDev --data '@./test/DiscourseUserEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: user' --header 'x-discourse-event: user_created' 9 | echo '' 10 | echo 'topic - slack' 11 | curl -X POST $WEBTASK_URL/SendToSlackDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created' 12 | echo '' 13 | echo 'post - slack' 14 | curl -X POST $WEBTASK_URL/SendToSlackDev --data '@./test/DiscoursePostEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: post' --header 'x-discourse-event: post_created' 15 | 16 | echo '' 17 | echo 'topic_created - slack-interactive' 18 | curl -X POST $WEBTASK_URL/SendToSlackInteractiveDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created' 19 | echo '' 20 | 21 | echo '' 22 | echo 'dismiss button - receive-slack' 23 | curl -X POST $WEBTASK_URL/ReceiveSlackInteractionDev --data '@./test/SlackInteractionDismiss.json' 24 | echo '' 25 | 26 | echo '' 27 | echo 'community button - receive-slack' 28 | curl -X POST $WEBTASK_URL/ReceiveSlackInteractionDev --data '@./test/SlackInteractionCommunity.json' 29 | echo '' 30 | 31 | echo '' 32 | echo 'user - keen' 33 | curl -X POST $WEBTASK_URL/SendToKeenDev --data '@./test/DiscourseUserEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: user' --header 'x-discourse-event: user_created' 34 | echo '' 35 | echo 'topic - keen' 36 | curl -X POST $WEBTASK_URL/SendToKeenDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created' 37 | echo '' 38 | echo 'post - keen' 39 | curl -X POST $WEBTASK_URL/SendToKeenDev --data '@./test/DiscoursePostEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: post' --header 'x-discourse-event: post_created' 40 | 41 | # comment in if you're using these webhooks 42 | # echo '' 43 | # echo 'cron - activity report' 44 | # curl -X POST $WEBTASK_URL/SendKeenReportToSlackDev --header 'Content-Type: application/json' 45 | # echo '' 46 | 47 | # echo '' 48 | # echo 'topic_created - helpscout' 49 | # curl -X POST $WEBTASK_URL/SendToHelpScoutDev --data '@./test/DiscourseTopicEvent.json' --header 'Content-Type: application/json' --header 'x-discourse-event-type: topic' --header 'x-discourse-event: topic_created' 50 | -------------------------------------------------------------------------------- /test/DiscoursePostEvent.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "id": 2618 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/DiscourseTopicEvent.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": { 3 | "id": 1608 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/DiscourseUserEvent.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": { 3 | "username": "dzello" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/SlackInteractionCommunity.json: -------------------------------------------------------------------------------- 1 | payload={ 2 | "actions": [ 3 | { 4 | "name": "topic-created-options", 5 | "type": "button", 6 | "value": "community" 7 | } 8 | ], 9 | "callback_id": "{\"topic\":{\"id\":1178},\"category\":{\"id\":4,\"name\":\"Staff\",\"slug\":\"staff\"},\"user\":{\"id\":1,\"name\":\"Josh\",\"username\":\"dzello\",\"email\":\"foo@foo.com\"}}", 10 | "team": { 11 | }, 12 | "channel": { 13 | }, 14 | "user": { 15 | "name": "josh", 16 | "email": "foo@foo.com" 17 | }, 18 | "original_message": { 19 | "text": "", 20 | "attachments": [{}], 21 | "type": "message", 22 | "subtype": "bot_message" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/SlackInteractionDismiss.json: -------------------------------------------------------------------------------- 1 | payload={ 2 | "actions": [ 3 | { 4 | "name": "topic-created-options", 5 | "type": "button", 6 | "value": "dismiss" 7 | } 8 | ], 9 | "callback_id": "{\"topic\":{\"id\":1178},\"category\":{\"id\":4,\"name\":\"Staff\",\"slug\":\"staff\"},\"user\":{\"id\":1,\"name\":\"Josh\",\"username\":\"dzello\",\"email\":\"foo@foo.com\"}}", 10 | "team": { 11 | }, 12 | "channel": { 13 | }, 14 | "user": { 15 | "name": "josh", 16 | "email": "foo@foo.com" 17 | }, 18 | "original_message": { 19 | "text": "", 20 | "attachments": [{}], 21 | "type": "message", 22 | "subtype": "bot_message" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | accepts@~1.3.3: 6 | version "1.3.3" 7 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" 8 | dependencies: 9 | mime-types "~2.1.11" 10 | negotiator "0.6.1" 11 | 12 | acorn-globals@^3.0.0: 13 | version "3.1.0" 14 | resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" 15 | dependencies: 16 | acorn "^4.0.4" 17 | 18 | acorn@^3.1.0, acorn@~3.3.0: 19 | version "3.3.0" 20 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" 21 | 22 | acorn@^4.0.4: 23 | version "4.0.11" 24 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0" 25 | 26 | acorn@~2.7.0: 27 | version "2.7.0" 28 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" 29 | 30 | ajv@^4.9.1: 31 | version "4.11.8" 32 | resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536" 33 | dependencies: 34 | co "^4.6.0" 35 | json-stable-stringify "^1.0.1" 36 | 37 | align-text@^0.1.1, align-text@^0.1.3: 38 | version "0.1.4" 39 | resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" 40 | dependencies: 41 | kind-of "^3.0.2" 42 | longest "^1.0.1" 43 | repeat-string "^1.5.2" 44 | 45 | amdefine@>=0.0.4: 46 | version "1.0.1" 47 | resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" 48 | 49 | array-flatten@1.1.1: 50 | version "1.1.1" 51 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 52 | 53 | asap@~2.0.3: 54 | version "2.0.5" 55 | resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" 56 | 57 | asn1@~0.2.3: 58 | version "0.2.3" 59 | resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" 60 | 61 | assert-plus@1.0.0, assert-plus@^1.0.0: 62 | version "1.0.0" 63 | resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" 64 | 65 | assert-plus@^0.2.0: 66 | version "0.2.0" 67 | resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" 68 | 69 | async@^1.4.0: 70 | version "1.5.2" 71 | resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" 72 | 73 | asynckit@^0.4.0: 74 | version "0.4.0" 75 | resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" 76 | 77 | aws-sign2@~0.6.0: 78 | version "0.6.0" 79 | resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" 80 | 81 | aws4@^1.2.1: 82 | version "1.6.0" 83 | resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" 84 | 85 | base64url@2.0.0, base64url@^2.0.0: 86 | version "2.0.0" 87 | resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" 88 | 89 | bcrypt-pbkdf@^1.0.0: 90 | version "1.0.1" 91 | resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" 92 | dependencies: 93 | tweetnacl "^0.14.3" 94 | 95 | bluebird@^3.2.1: 96 | version "3.5.0" 97 | resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" 98 | 99 | body-parser@^1.17.1: 100 | version "1.17.1" 101 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.17.1.tgz#75b3bc98ddd6e7e0d8ffe750dfaca5c66993fa47" 102 | dependencies: 103 | bytes "2.4.0" 104 | content-type "~1.0.2" 105 | debug "2.6.1" 106 | depd "~1.1.0" 107 | http-errors "~1.6.1" 108 | iconv-lite "0.4.15" 109 | on-finished "~2.3.0" 110 | qs "6.4.0" 111 | raw-body "~2.2.0" 112 | type-is "~1.6.14" 113 | 114 | boom@2.x.x, boom@^2.7.2: 115 | version "2.10.1" 116 | resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" 117 | dependencies: 118 | hoek "2.x.x" 119 | 120 | buffer-equal-constant-time@1.0.1: 121 | version "1.0.1" 122 | resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" 123 | 124 | bytes@2.4.0: 125 | version "2.4.0" 126 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.4.0.tgz#7d97196f9d5baf7f6935e25985549edd2a6c2339" 127 | 128 | camelcase@^1.0.2: 129 | version "1.2.1" 130 | resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" 131 | 132 | caseless@~0.12.0: 133 | version "0.12.0" 134 | resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" 135 | 136 | center-align@^0.1.1: 137 | version "0.1.3" 138 | resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" 139 | dependencies: 140 | align-text "^0.1.3" 141 | lazy-cache "^1.0.3" 142 | 143 | character-parser@^2.1.1: 144 | version "2.2.0" 145 | resolved "https://registry.yarnpkg.com/character-parser/-/character-parser-2.2.0.tgz#c7ce28f36d4bcd9744e5ffc2c5fcde1c73261fc0" 146 | dependencies: 147 | is-regex "^1.0.3" 148 | 149 | clean-css@^3.3.0: 150 | version "3.4.25" 151 | resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-3.4.25.tgz#9e9a52d5c1e6bc5123e1b2783fa65fe958946ede" 152 | dependencies: 153 | commander "2.8.x" 154 | source-map "0.4.x" 155 | 156 | cliui@^2.1.0: 157 | version "2.1.0" 158 | resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" 159 | dependencies: 160 | center-align "^0.1.1" 161 | right-align "^0.1.1" 162 | wordwrap "0.0.2" 163 | 164 | co@^4.6.0: 165 | version "4.6.0" 166 | resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" 167 | 168 | combined-stream@^1.0.5, combined-stream@~1.0.5: 169 | version "1.0.5" 170 | resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" 171 | dependencies: 172 | delayed-stream "~1.0.0" 173 | 174 | commander@2.8.x: 175 | version "2.8.1" 176 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" 177 | dependencies: 178 | graceful-readlink ">= 1.0.0" 179 | 180 | component-emitter@^1.2.0, component-emitter@~1.2.0: 181 | version "1.2.1" 182 | resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" 183 | 184 | constantinople@^3.0.1: 185 | version "3.1.0" 186 | resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-3.1.0.tgz#7569caa8aa3f8d5935d62e1fa96f9f702cd81c79" 187 | dependencies: 188 | acorn "^3.1.0" 189 | is-expression "^2.0.1" 190 | 191 | content-disposition@0.5.2: 192 | version "0.5.2" 193 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" 194 | 195 | content-type@~1.0.2: 196 | version "1.0.2" 197 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" 198 | 199 | cookie-signature@1.0.6: 200 | version "1.0.6" 201 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 202 | 203 | cookie@0.3.1: 204 | version "0.3.1" 205 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" 206 | 207 | cookiejar@2.0.6: 208 | version "2.0.6" 209 | resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.0.6.tgz#0abf356ad00d1c5a219d88d44518046dd026acfe" 210 | 211 | core-util-is@~1.0.0: 212 | version "1.0.2" 213 | resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" 214 | 215 | cryptiles@2.x.x: 216 | version "2.0.5" 217 | resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" 218 | dependencies: 219 | boom "2.x.x" 220 | 221 | dashdash@^1.12.0: 222 | version "1.14.1" 223 | resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" 224 | dependencies: 225 | assert-plus "^1.0.0" 226 | 227 | debug@2, debug@2.6.4: 228 | version "2.6.4" 229 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.4.tgz#7586a9b3c39741c0282ae33445c4e8ac74734fe0" 230 | dependencies: 231 | ms "0.7.3" 232 | 233 | debug@2.6.1: 234 | version "2.6.1" 235 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.1.tgz#79855090ba2c4e3115cc7d8769491d58f0491351" 236 | dependencies: 237 | ms "0.7.2" 238 | 239 | decamelize@^1.0.0: 240 | version "1.2.0" 241 | resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" 242 | 243 | delayed-stream@~1.0.0: 244 | version "1.0.0" 245 | resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" 246 | 247 | depd@1.1.0, depd@~1.1.0: 248 | version "1.1.0" 249 | resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3" 250 | 251 | destroy@~1.0.4: 252 | version "1.0.4" 253 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" 254 | 255 | doctypes@^1.0.0: 256 | version "1.1.0" 257 | resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" 258 | 259 | ecc-jsbn@~0.1.1: 260 | version "0.1.1" 261 | resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" 262 | dependencies: 263 | jsbn "~0.1.0" 264 | 265 | ecdsa-sig-formatter@1.0.9: 266 | version "1.0.9" 267 | resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1" 268 | dependencies: 269 | base64url "^2.0.0" 270 | safe-buffer "^5.0.1" 271 | 272 | ee-first@1.1.1: 273 | version "1.1.1" 274 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 275 | 276 | encodeurl@~1.0.1: 277 | version "1.0.1" 278 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" 279 | 280 | escape-html@~1.0.3: 281 | version "1.0.3" 282 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 283 | 284 | etag@~1.8.0: 285 | version "1.8.0" 286 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051" 287 | 288 | express@4.15.x: 289 | version "4.15.2" 290 | resolved "https://registry.yarnpkg.com/express/-/express-4.15.2.tgz#af107fc148504457f2dca9a6f2571d7129b97b35" 291 | dependencies: 292 | accepts "~1.3.3" 293 | array-flatten "1.1.1" 294 | content-disposition "0.5.2" 295 | content-type "~1.0.2" 296 | cookie "0.3.1" 297 | cookie-signature "1.0.6" 298 | debug "2.6.1" 299 | depd "~1.1.0" 300 | encodeurl "~1.0.1" 301 | escape-html "~1.0.3" 302 | etag "~1.8.0" 303 | finalhandler "~1.0.0" 304 | fresh "0.5.0" 305 | merge-descriptors "1.0.1" 306 | methods "~1.1.2" 307 | on-finished "~2.3.0" 308 | parseurl "~1.3.1" 309 | path-to-regexp "0.1.7" 310 | proxy-addr "~1.1.3" 311 | qs "6.4.0" 312 | range-parser "~1.2.0" 313 | send "0.15.1" 314 | serve-static "1.12.1" 315 | setprototypeof "1.0.3" 316 | statuses "~1.3.1" 317 | type-is "~1.6.14" 318 | utils-merge "1.0.0" 319 | vary "~1.1.0" 320 | 321 | extend@3.0.0: 322 | version "3.0.0" 323 | resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" 324 | 325 | extend@~3.0.0: 326 | version "3.0.1" 327 | resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" 328 | 329 | extsprintf@1.0.2: 330 | version "1.0.2" 331 | resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" 332 | 333 | finalhandler@~1.0.0: 334 | version "1.0.2" 335 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.2.tgz#d0e36f9dbc557f2de14423df6261889e9d60c93a" 336 | dependencies: 337 | debug "2.6.4" 338 | encodeurl "~1.0.1" 339 | escape-html "~1.0.3" 340 | on-finished "~2.3.0" 341 | parseurl "~1.3.1" 342 | statuses "~1.3.1" 343 | unpipe "~1.0.0" 344 | 345 | forever-agent@~0.6.1: 346 | version "0.6.1" 347 | resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" 348 | 349 | form-data@1.0.0-rc3: 350 | version "1.0.0-rc3" 351 | resolved "https://registry.yarnpkg.com/form-data/-/form-data-1.0.0-rc3.tgz#d35bc62e7fbc2937ae78f948aaa0d38d90607577" 352 | dependencies: 353 | async "^1.4.0" 354 | combined-stream "^1.0.5" 355 | mime-types "^2.1.3" 356 | 357 | form-data@~2.1.1: 358 | version "2.1.4" 359 | resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1" 360 | dependencies: 361 | asynckit "^0.4.0" 362 | combined-stream "^1.0.5" 363 | mime-types "^2.1.12" 364 | 365 | formidable@~1.0.14: 366 | version "1.0.17" 367 | resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.0.17.tgz#ef5491490f9433b705faa77249c99029ae348559" 368 | 369 | forwarded@~0.1.0: 370 | version "0.1.0" 371 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" 372 | 373 | fresh@0.5.0: 374 | version "0.5.0" 375 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e" 376 | 377 | function-bind@^1.0.2: 378 | version "1.1.0" 379 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771" 380 | 381 | getpass@^0.1.1: 382 | version "0.1.7" 383 | resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" 384 | dependencies: 385 | assert-plus "^1.0.0" 386 | 387 | "graceful-readlink@>= 1.0.0": 388 | version "1.0.1" 389 | resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" 390 | 391 | har-schema@^1.0.5: 392 | version "1.0.5" 393 | resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" 394 | 395 | har-validator@~4.2.1: 396 | version "4.2.1" 397 | resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" 398 | dependencies: 399 | ajv "^4.9.1" 400 | har-schema "^1.0.5" 401 | 402 | has@^1.0.1: 403 | version "1.0.1" 404 | resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" 405 | dependencies: 406 | function-bind "^1.0.2" 407 | 408 | hawk@~3.1.3: 409 | version "3.1.3" 410 | resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" 411 | dependencies: 412 | boom "2.x.x" 413 | cryptiles "2.x.x" 414 | hoek "2.x.x" 415 | sntp "1.x.x" 416 | 417 | hoek@2.x.x: 418 | version "2.16.3" 419 | resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" 420 | 421 | http-errors@~1.6.1: 422 | version "1.6.1" 423 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.1.tgz#5f8b8ed98aca545656bf572997387f904a722257" 424 | dependencies: 425 | depd "1.1.0" 426 | inherits "2.0.3" 427 | setprototypeof "1.0.3" 428 | statuses ">= 1.3.1 < 2" 429 | 430 | http-signature@~1.1.0: 431 | version "1.1.1" 432 | resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" 433 | dependencies: 434 | assert-plus "^0.2.0" 435 | jsprim "^1.2.2" 436 | sshpk "^1.7.0" 437 | 438 | iconv-lite@0.4.15: 439 | version "0.4.15" 440 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb" 441 | 442 | inherits@2.0.3, inherits@~2.0.1: 443 | version "2.0.3" 444 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 445 | 446 | ipaddr.js@1.3.0: 447 | version "1.3.0" 448 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.3.0.tgz#1e03a52fdad83a8bbb2b25cbf4998b4cffcd3dec" 449 | 450 | is-buffer@^1.1.5: 451 | version "1.1.5" 452 | resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" 453 | 454 | is-expression@^1.0.0: 455 | version "1.0.2" 456 | resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-1.0.2.tgz#a345b96218e9df21e65510c39b4dc3602fdd3f96" 457 | dependencies: 458 | acorn "~2.7.0" 459 | object-assign "^4.0.1" 460 | 461 | is-expression@^2.0.1: 462 | version "2.1.0" 463 | resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-2.1.0.tgz#91be9d47debcfef077977e9722be6dcfb4465ef0" 464 | dependencies: 465 | acorn "~3.3.0" 466 | object-assign "^4.0.1" 467 | 468 | is-promise@^2.0.0: 469 | version "2.1.0" 470 | resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" 471 | 472 | is-regex@^1.0.3: 473 | version "1.0.4" 474 | resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" 475 | dependencies: 476 | has "^1.0.1" 477 | 478 | is-typedarray@~1.0.0: 479 | version "1.0.0" 480 | resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" 481 | 482 | isarray@0.0.1: 483 | version "0.0.1" 484 | resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" 485 | 486 | isstream@~0.1.2: 487 | version "0.1.2" 488 | resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" 489 | 490 | jodid25519@^1.0.0: 491 | version "1.0.2" 492 | resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" 493 | dependencies: 494 | jsbn "~0.1.0" 495 | 496 | js-cookie@2.1.0: 497 | version "2.1.0" 498 | resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.1.0.tgz#479c20d0a0bb6cab81491f917788cd025d6452f0" 499 | 500 | js-stringify@^1.0.1: 501 | version "1.0.2" 502 | resolved "https://registry.yarnpkg.com/js-stringify/-/js-stringify-1.0.2.tgz#1736fddfd9724f28a3682adc6230ae7e4e9679db" 503 | 504 | jsbn@~0.1.0: 505 | version "0.1.1" 506 | resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" 507 | 508 | json-schema@0.2.3: 509 | version "0.2.3" 510 | resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" 511 | 512 | json-stable-stringify@^1.0.1: 513 | version "1.0.1" 514 | resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" 515 | dependencies: 516 | jsonify "~0.0.0" 517 | 518 | json-stringify-safe@~5.0.1: 519 | version "5.0.1" 520 | resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" 521 | 522 | jsonify@~0.0.0: 523 | version "0.0.0" 524 | resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" 525 | 526 | jsonwebtoken@^5.7.0: 527 | version "5.7.0" 528 | resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-5.7.0.tgz#1c90f9a86ce5b748f5f979c12b70402b4afcddb4" 529 | dependencies: 530 | jws "^3.0.0" 531 | ms "^0.7.1" 532 | xtend "^4.0.1" 533 | 534 | jsprim@^1.2.2: 535 | version "1.4.0" 536 | resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.0.tgz#a3b87e40298d8c380552d8cc7628a0bb95a22918" 537 | dependencies: 538 | assert-plus "1.0.0" 539 | extsprintf "1.0.2" 540 | json-schema "0.2.3" 541 | verror "1.3.6" 542 | 543 | jstransformer@0.0.3: 544 | version "0.0.3" 545 | resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-0.0.3.tgz#347495bd3fe1cfe8f03e2d71578acb9024826cf5" 546 | dependencies: 547 | is-promise "^2.0.0" 548 | promise "^7.0.1" 549 | 550 | jwa@^1.1.4: 551 | version "1.1.5" 552 | resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5" 553 | dependencies: 554 | base64url "2.0.0" 555 | buffer-equal-constant-time "1.0.1" 556 | ecdsa-sig-formatter "1.0.9" 557 | safe-buffer "^5.0.1" 558 | 559 | jws@^3.0.0: 560 | version "3.1.4" 561 | resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2" 562 | dependencies: 563 | base64url "^2.0.0" 564 | jwa "^1.1.4" 565 | safe-buffer "^5.0.1" 566 | 567 | keen-analysis@^1.2.2: 568 | version "1.2.2" 569 | resolved "https://registry.yarnpkg.com/keen-analysis/-/keen-analysis-1.2.2.tgz#ace86090953d6a0100f67908628df234ca9941b2" 570 | dependencies: 571 | bluebird "^3.2.1" 572 | keen-core "0.1.2" 573 | 574 | keen-core@0.1.2: 575 | version "0.1.2" 576 | resolved "https://registry.yarnpkg.com/keen-core/-/keen-core-0.1.2.tgz#e8c107fdef227f56e6cf12eb59ef41ff38c37778" 577 | dependencies: 578 | component-emitter "^1.2.0" 579 | 580 | keen-tracking@^1.1.3: 581 | version "1.1.3" 582 | resolved "https://registry.yarnpkg.com/keen-tracking/-/keen-tracking-1.1.3.tgz#1159d2066b90474472fb611ac31c37bc51d94b72" 583 | dependencies: 584 | component-emitter "^1.2.0" 585 | js-cookie "2.1.0" 586 | keen-core "0.1.2" 587 | 588 | kind-of@^3.0.2: 589 | version "3.2.0" 590 | resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.0.tgz#b58abe4d5c044ad33726a8c1525b48cf891bff07" 591 | dependencies: 592 | is-buffer "^1.1.5" 593 | 594 | lazy-cache@^1.0.3: 595 | version "1.0.4" 596 | resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" 597 | 598 | lodash.find@^4.6.0: 599 | version "4.6.0" 600 | resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-4.6.0.tgz#cb0704d47ab71789ffa0de8b97dd926fb88b13b1" 601 | 602 | lodash.map@^4.6.0: 603 | version "4.6.0" 604 | resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" 605 | 606 | lodash.pick@^4.4.0: 607 | version "4.4.0" 608 | resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" 609 | 610 | lodash.reduce@^4.6.0: 611 | version "4.6.0" 612 | resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" 613 | 614 | lodash.sortby@^4.7.0: 615 | version "4.7.0" 616 | resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" 617 | 618 | longest@^1.0.1: 619 | version "1.0.1" 620 | resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" 621 | 622 | media-typer@0.3.0: 623 | version "0.3.0" 624 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 625 | 626 | merge-descriptors@1.0.1: 627 | version "1.0.1" 628 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 629 | 630 | methods@~1.1.1, methods@~1.1.2: 631 | version "1.1.2" 632 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 633 | 634 | mime-db@~1.27.0: 635 | version "1.27.0" 636 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" 637 | 638 | mime-types@^2.1.12, mime-types@^2.1.3, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7: 639 | version "2.1.15" 640 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" 641 | dependencies: 642 | mime-db "~1.27.0" 643 | 644 | mime@1.3.4: 645 | version "1.3.4" 646 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" 647 | 648 | ms@0.7.2, ms@^0.7.1: 649 | version "0.7.2" 650 | resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" 651 | 652 | ms@0.7.3: 653 | version "0.7.3" 654 | resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.3.tgz#708155a5e44e33f5fd0fc53e81d0d40a91be1fff" 655 | 656 | negotiator@0.6.1: 657 | version "0.6.1" 658 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" 659 | 660 | oauth-sign@~0.8.1: 661 | version "0.8.2" 662 | resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" 663 | 664 | object-assign@^4.0.1: 665 | version "4.1.1" 666 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 667 | 668 | on-finished@~2.3.0: 669 | version "2.3.0" 670 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" 671 | dependencies: 672 | ee-first "1.1.1" 673 | 674 | parseurl@~1.3.1: 675 | version "1.3.1" 676 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56" 677 | 678 | path-parse@^1.0.5: 679 | version "1.0.5" 680 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" 681 | 682 | path-to-regexp@0.1.7: 683 | version "0.1.7" 684 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 685 | 686 | performance-now@^0.2.0: 687 | version "0.2.0" 688 | resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" 689 | 690 | promise@^7.0.1: 691 | version "7.1.1" 692 | resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf" 693 | dependencies: 694 | asap "~2.0.3" 695 | 696 | proxy-addr@~1.1.3: 697 | version "1.1.4" 698 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.4.tgz#27e545f6960a44a627d9b44467e35c1b6b4ce2f3" 699 | dependencies: 700 | forwarded "~0.1.0" 701 | ipaddr.js "1.3.0" 702 | 703 | pug-attrs@^0.0.0: 704 | version "0.0.0" 705 | resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-0.0.0.tgz#9ffeab30be1723d1143f1b093140c8c3439ca0cb" 706 | dependencies: 707 | constantinople "^3.0.1" 708 | js-stringify "^1.0.1" 709 | pug-runtime "^0.0.0" 710 | 711 | pug-code-gen@0.0.0: 712 | version "0.0.0" 713 | resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-0.0.0.tgz#a4ffc0f66235bb8d8ca96dab503205feb5d3c584" 714 | dependencies: 715 | constantinople "^3.0.1" 716 | doctypes "^1.0.0" 717 | js-stringify "^1.0.1" 718 | pug-attrs "^0.0.0" 719 | pug-runtime "^0.0.0" 720 | void-elements "^2.0.1" 721 | with "^5.0.0" 722 | 723 | pug-error@^0.0.0: 724 | version "0.0.0" 725 | resolved "https://registry.yarnpkg.com/pug-error/-/pug-error-0.0.0.tgz#dd264a39c20d65487df85ff5663097862a16db78" 726 | 727 | pug-filters@1.1.0: 728 | version "1.1.0" 729 | resolved "https://registry.yarnpkg.com/pug-filters/-/pug-filters-1.1.0.tgz#c17b50a0ed5fc7282314cc411b04bf64d2dc9e13" 730 | dependencies: 731 | clean-css "^3.3.0" 732 | constantinople "^3.0.1" 733 | jstransformer "0.0.3" 734 | pug-error "^0.0.0" 735 | pug-walk "^0.0.0" 736 | resolve "^1.1.6" 737 | uglify-js "^2.6.1" 738 | 739 | pug-lexer@0.0.0: 740 | version "0.0.0" 741 | resolved "https://registry.yarnpkg.com/pug-lexer/-/pug-lexer-0.0.0.tgz#202246e96666973099219b619047f0c75f52fb06" 742 | dependencies: 743 | character-parser "^2.1.1" 744 | is-expression "^1.0.0" 745 | pug-error "^0.0.0" 746 | 747 | pug-linker@0.0.0: 748 | version "0.0.0" 749 | resolved "https://registry.yarnpkg.com/pug-linker/-/pug-linker-0.0.0.tgz#8cae368e8911691a53e5d00feff38a9f1752e77e" 750 | dependencies: 751 | pug-error "^0.0.0" 752 | pug-walk "^0.0.0" 753 | 754 | pug-loader@0.0.0: 755 | version "0.0.0" 756 | resolved "https://registry.yarnpkg.com/pug-loader/-/pug-loader-0.0.0.tgz#2994cc855db098a62ab51b97fc150bc06ab919bf" 757 | dependencies: 758 | pug-walk "0.0.0" 759 | 760 | pug-parser@0.0.0: 761 | version "0.0.0" 762 | resolved "https://registry.yarnpkg.com/pug-parser/-/pug-parser-0.0.0.tgz#08831eabd753590d45573247e546ac07c4e6e523" 763 | dependencies: 764 | pug-error "^0.0.0" 765 | token-stream "0.0.1" 766 | 767 | pug-runtime@0.0.0, pug-runtime@^0.0.0: 768 | version "0.0.0" 769 | resolved "https://registry.yarnpkg.com/pug-runtime/-/pug-runtime-0.0.0.tgz#f8105094f78ac893cdb19746a7cb0916fd418697" 770 | 771 | pug-strip-comments@0.0.1: 772 | version "0.0.1" 773 | resolved "https://registry.yarnpkg.com/pug-strip-comments/-/pug-strip-comments-0.0.1.tgz#ac346bb773d82492bf922dae2d4681a20cf0638f" 774 | dependencies: 775 | pug-error "^0.0.0" 776 | 777 | pug-walk@0.0.0, pug-walk@^0.0.0: 778 | version "0.0.0" 779 | resolved "https://registry.yarnpkg.com/pug-walk/-/pug-walk-0.0.0.tgz#d16ed9429e6ae71698fedeeaee4734ea81ecd52a" 780 | 781 | pug@^0.1.0: 782 | version "0.1.0" 783 | resolved "https://registry.yarnpkg.com/pug/-/pug-0.1.0.tgz#6958bf32ad56378b048f01949b380d470d8b5cc9" 784 | dependencies: 785 | pug-code-gen "0.0.0" 786 | pug-filters "1.1.0" 787 | pug-lexer "0.0.0" 788 | pug-linker "0.0.0" 789 | pug-loader "0.0.0" 790 | pug-parser "0.0.0" 791 | pug-runtime "0.0.0" 792 | pug-strip-comments "0.0.1" 793 | 794 | punycode@^1.4.1: 795 | version "1.4.1" 796 | resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" 797 | 798 | qs@2.3.3: 799 | version "2.3.3" 800 | resolved "https://registry.yarnpkg.com/qs/-/qs-2.3.3.tgz#e9e85adbe75da0bbe4c8e0476a086290f863b404" 801 | 802 | qs@6.4.0, qs@~6.4.0: 803 | version "6.4.0" 804 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" 805 | 806 | range-parser@~1.2.0: 807 | version "1.2.0" 808 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" 809 | 810 | raw-body@~2.2.0: 811 | version "2.2.0" 812 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" 813 | dependencies: 814 | bytes "2.4.0" 815 | iconv-lite "0.4.15" 816 | unpipe "1.0.0" 817 | 818 | readable-stream@1.0.27-1: 819 | version "1.0.27-1" 820 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.27-1.tgz#6b67983c20357cefd07f0165001a16d710d91078" 821 | dependencies: 822 | core-util-is "~1.0.0" 823 | inherits "~2.0.1" 824 | isarray "0.0.1" 825 | string_decoder "~0.10.x" 826 | 827 | reduce-component@1.0.1: 828 | version "1.0.1" 829 | resolved "https://registry.yarnpkg.com/reduce-component/-/reduce-component-1.0.1.tgz#e0c93542c574521bea13df0f9488ed82ab77c5da" 830 | 831 | repeat-string@^1.5.2: 832 | version "1.6.1" 833 | resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" 834 | 835 | request@^2.81.0: 836 | version "2.81.0" 837 | resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" 838 | dependencies: 839 | aws-sign2 "~0.6.0" 840 | aws4 "^1.2.1" 841 | caseless "~0.12.0" 842 | combined-stream "~1.0.5" 843 | extend "~3.0.0" 844 | forever-agent "~0.6.1" 845 | form-data "~2.1.1" 846 | har-validator "~4.2.1" 847 | hawk "~3.1.3" 848 | http-signature "~1.1.0" 849 | is-typedarray "~1.0.0" 850 | isstream "~0.1.2" 851 | json-stringify-safe "~5.0.1" 852 | mime-types "~2.1.7" 853 | oauth-sign "~0.8.1" 854 | performance-now "^0.2.0" 855 | qs "~6.4.0" 856 | safe-buffer "^5.0.1" 857 | stringstream "~0.0.4" 858 | tough-cookie "~2.3.0" 859 | tunnel-agent "^0.6.0" 860 | uuid "^3.0.0" 861 | 862 | resolve@^1.1.6: 863 | version "1.3.3" 864 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.3.tgz#655907c3469a8680dc2de3a275a8fdd69691f0e5" 865 | dependencies: 866 | path-parse "^1.0.5" 867 | 868 | right-align@^0.1.1: 869 | version "0.1.3" 870 | resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" 871 | dependencies: 872 | align-text "^0.1.1" 873 | 874 | safe-buffer@^5.0.1: 875 | version "5.0.1" 876 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" 877 | 878 | send@0.15.1: 879 | version "0.15.1" 880 | resolved "https://registry.yarnpkg.com/send/-/send-0.15.1.tgz#8a02354c26e6f5cca700065f5f0cdeba90ec7b5f" 881 | dependencies: 882 | debug "2.6.1" 883 | depd "~1.1.0" 884 | destroy "~1.0.4" 885 | encodeurl "~1.0.1" 886 | escape-html "~1.0.3" 887 | etag "~1.8.0" 888 | fresh "0.5.0" 889 | http-errors "~1.6.1" 890 | mime "1.3.4" 891 | ms "0.7.2" 892 | on-finished "~2.3.0" 893 | range-parser "~1.2.0" 894 | statuses "~1.3.1" 895 | 896 | serve-static@1.12.1: 897 | version "1.12.1" 898 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.1.tgz#7443a965e3ced647aceb5639fa06bf4d1bbe0039" 899 | dependencies: 900 | encodeurl "~1.0.1" 901 | escape-html "~1.0.3" 902 | parseurl "~1.3.1" 903 | send "0.15.1" 904 | 905 | setprototypeof@1.0.3: 906 | version "1.0.3" 907 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" 908 | 909 | sntp@1.x.x: 910 | version "1.0.9" 911 | resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" 912 | dependencies: 913 | hoek "2.x.x" 914 | 915 | source-map@0.4.x: 916 | version "0.4.4" 917 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" 918 | dependencies: 919 | amdefine ">=0.0.4" 920 | 921 | source-map@~0.5.1: 922 | version "0.5.6" 923 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" 924 | 925 | sshpk@^1.7.0: 926 | version "1.13.0" 927 | resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.0.tgz#ff2a3e4fd04497555fed97b39a0fd82fafb3a33c" 928 | dependencies: 929 | asn1 "~0.2.3" 930 | assert-plus "^1.0.0" 931 | dashdash "^1.12.0" 932 | getpass "^0.1.1" 933 | optionalDependencies: 934 | bcrypt-pbkdf "^1.0.0" 935 | ecc-jsbn "~0.1.1" 936 | jodid25519 "^1.0.0" 937 | jsbn "~0.1.0" 938 | tweetnacl "~0.14.0" 939 | 940 | "statuses@>= 1.3.1 < 2", statuses@~1.3.1: 941 | version "1.3.1" 942 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" 943 | 944 | string_decoder@~0.10.x: 945 | version "0.10.31" 946 | resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" 947 | 948 | stringstream@~0.0.4: 949 | version "0.0.5" 950 | resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" 951 | 952 | superagent@^1.8.3: 953 | version "1.8.5" 954 | resolved "https://registry.yarnpkg.com/superagent/-/superagent-1.8.5.tgz#1c0ddc3af30e80eb84ebc05cb2122da8fe940b55" 955 | dependencies: 956 | component-emitter "~1.2.0" 957 | cookiejar "2.0.6" 958 | debug "2" 959 | extend "3.0.0" 960 | form-data "1.0.0-rc3" 961 | formidable "~1.0.14" 962 | methods "~1.1.1" 963 | mime "1.3.4" 964 | qs "2.3.3" 965 | readable-stream "1.0.27-1" 966 | reduce-component "1.0.1" 967 | 968 | token-stream@0.0.1: 969 | version "0.0.1" 970 | resolved "https://registry.yarnpkg.com/token-stream/-/token-stream-0.0.1.tgz#ceeefc717a76c4316f126d0b9dbaa55d7e7df01a" 971 | 972 | tough-cookie@~2.3.0: 973 | version "2.3.2" 974 | resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" 975 | dependencies: 976 | punycode "^1.4.1" 977 | 978 | tunnel-agent@^0.6.0: 979 | version "0.6.0" 980 | resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" 981 | dependencies: 982 | safe-buffer "^5.0.1" 983 | 984 | tweetnacl@^0.14.3, tweetnacl@~0.14.0: 985 | version "0.14.5" 986 | resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" 987 | 988 | type-is@~1.6.14: 989 | version "1.6.15" 990 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" 991 | dependencies: 992 | media-typer "0.3.0" 993 | mime-types "~2.1.15" 994 | 995 | uglify-js@^2.6.1: 996 | version "2.8.23" 997 | resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.23.tgz#8230dd9783371232d62a7821e2cf9a817270a8a0" 998 | dependencies: 999 | source-map "~0.5.1" 1000 | yargs "~3.10.0" 1001 | optionalDependencies: 1002 | uglify-to-browserify "~1.0.0" 1003 | 1004 | uglify-to-browserify@~1.0.0: 1005 | version "1.0.2" 1006 | resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" 1007 | 1008 | unpipe@1.0.0, unpipe@~1.0.0: 1009 | version "1.0.0" 1010 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 1011 | 1012 | utils-merge@1.0.0: 1013 | version "1.0.0" 1014 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" 1015 | 1016 | uuid@^3.0.0: 1017 | version "3.0.1" 1018 | resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" 1019 | 1020 | vary@~1.1.0: 1021 | version "1.1.1" 1022 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37" 1023 | 1024 | verror@1.3.6: 1025 | version "1.3.6" 1026 | resolved "https://registry.yarnpkg.com/verror/-/verror-1.3.6.tgz#cff5df12946d297d2baaefaa2689e25be01c005c" 1027 | dependencies: 1028 | extsprintf "1.0.2" 1029 | 1030 | void-elements@^2.0.1: 1031 | version "2.0.1" 1032 | resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" 1033 | 1034 | webtask-tools@^3.2.0: 1035 | version "3.2.0" 1036 | resolved "https://registry.yarnpkg.com/webtask-tools/-/webtask-tools-3.2.0.tgz#bd6dda8f739bbb2e1c69479a5883085e0293f69d" 1037 | dependencies: 1038 | boom "^2.7.2" 1039 | jsonwebtoken "^5.7.0" 1040 | pug "^0.1.0" 1041 | safe-buffer "^5.0.1" 1042 | superagent "^1.8.3" 1043 | 1044 | window-size@0.1.0: 1045 | version "0.1.0" 1046 | resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" 1047 | 1048 | with@^5.0.0: 1049 | version "5.1.1" 1050 | resolved "https://registry.yarnpkg.com/with/-/with-5.1.1.tgz#fa4daa92daf32c4ea94ed453c81f04686b575dfe" 1051 | dependencies: 1052 | acorn "^3.1.0" 1053 | acorn-globals "^3.0.0" 1054 | 1055 | wordwrap@0.0.2: 1056 | version "0.0.2" 1057 | resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" 1058 | 1059 | xtend@^4.0.1: 1060 | version "4.0.1" 1061 | resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" 1062 | 1063 | yargs@~3.10.0: 1064 | version "3.10.0" 1065 | resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" 1066 | dependencies: 1067 | camelcase "^1.0.2" 1068 | cliui "^2.1.0" 1069 | decamelize "^1.0.0" 1070 | window-size "0.1.0" 1071 | --------------------------------------------------------------------------------