├── .gitignore ├── LICENSE ├── images └── quackbot-icon_108.png ├── lib ├── .DS_Store ├── aws │ ├── lambda-get-sns-message.js │ ├── lambda-invoke-function.js │ └── sns-publish-message.js ├── readme.md └── slack │ ├── api │ └── slack-app-verify-endpoint.js │ └── messages │ ├── slack-reply-to-slash-command.js │ └── slack-send-message.js ├── oauth-dancer ├── index.js ├── lib │ ├── config │ │ └── config.sample.js │ ├── migrations │ │ ├── 20170926170020-create_teams.js │ │ ├── 20170926170026-create_authorizations.js │ │ └── 20170928232847-add_team_id_to_authorizations.js │ └── models │ │ ├── authorization.js │ │ ├── db.js │ │ └── team.js ├── package-lock.json ├── package.json └── todo.md ├── readme.md ├── slack-events-api-bots ├── .DS_Store ├── README.md ├── archive │ ├── index.js │ ├── package.json │ ├── src │ │ └── slack-send-message.js │ └── test.js ├── cliches │ ├── README.md │ ├── data │ │ ├── 681cliches.tsv │ │ ├── prowritingaid.tsv │ │ ├── quartz.tsv │ │ └── wapo.tsv │ ├── index.js │ ├── package.json │ ├── src │ │ └── slack-send-message.js │ └── test.js ├── databot │ ├── NOTES.md │ ├── README.md │ ├── index.js │ ├── package.json │ ├── src │ │ └── slack-send-message.js │ └── test.js └── screenshot │ ├── .DS_Store │ ├── .gitignore │ ├── config.js │ ├── index.js │ ├── inject │ ├── .DS_Store │ └── css │ │ └── twitter.css │ ├── package.json │ └── src │ ├── capture-screenshot.js │ ├── crop-screenshot.js │ ├── get-element-rect.js │ ├── inject-stylesheet.js │ ├── save-image.js │ ├── slack-send-message.js │ └── utils.js ├── slack-events-api-gateway ├── index.js ├── package.json ├── readme.md ├── routes │ └── messages-post.js └── src │ └── lambda-invoke-function.js ├── slack-events-api-message-handler ├── commands.js ├── index.js ├── lib │ ├── config │ │ ├── config.js │ │ └── config.sample.js │ └── models │ │ ├── authorization.js │ │ ├── db.js │ │ ├── team.js │ │ └── user.js ├── package-lock.json ├── package.json └── src │ ├── handle-shared-file.js │ ├── lambda-invoke-function.js │ ├── process-with-nlp.js │ ├── respond-on-error.js │ ├── route-message.js │ └── slack-send-message.js ├── slack-slash-command-router ├── .eslintrc ├── commands.js ├── index.js ├── package.json ├── readme.md ├── routes │ └── messages-post.js └── src │ ├── lambda-invoke-function.js │ ├── process-message.js │ ├── route-message.js │ └── sns-publish-message.js ├── slack-slash-commands ├── .DS_Store ├── examples │ └── delayed-response │ │ ├── index.js │ │ ├── package.json │ │ ├── readme.md │ │ └── src │ │ ├── lambda-get-sns-message.js │ │ └── slack-reply-to-slash-command.js ├── ping │ ├── index.js │ ├── package.json │ └── readme.md └── screenshot │ ├── .DS_Store │ ├── .eslintrc │ ├── .gitignore │ ├── README.md │ ├── config.js │ ├── index.js │ ├── inject │ └── css │ │ └── twitter.css │ ├── package.json │ └── src │ ├── capture-screenshot.js │ ├── crop-screenshot.js │ ├── get-element-rect.js │ ├── inject-stylesheet.js │ ├── save-image.js │ ├── slack-reply-to-slash-command.js │ └── utils.js ├── test.json └── utility ├── .DS_Store └── screenshot-compositor ├── .DS_Store ├── config.js ├── index.js ├── inject └── css │ └── twitter.css ├── package-lock.json ├── package.json └── src ├── capture-screenshot.js ├── combine-images.js ├── crop-screenshot.js ├── get-element-rect.js ├── inject-stylesheet.js ├── launch-chrome.js ├── prepare-chrome.js ├── save-to-s3.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | *.env* 4 | claudia.json 5 | 6 | # John Keefe’s global git ignore 7 | 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | .env 12 | 13 | *.log 14 | scratch.* 15 | 16 | # Icon must end with two \r 17 | Icon 18 | 19 | 20 | # Thumbnails 21 | ._* 22 | 23 | # Files that might appear on external disk 24 | .Spotlight-V100 25 | .Trashes 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | # Node Dependencies 35 | # (use npm install to load them instead, or see readme) 36 | node_modules 37 | 38 | # Temporary keys 39 | keys.json 40 | 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Quartz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /images/quackbot-icon_108.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/images/quackbot-icon_108.png -------------------------------------------------------------------------------- /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/lib/.DS_Store -------------------------------------------------------------------------------- /lib/aws/lambda-get-sns-message.js: -------------------------------------------------------------------------------- 1 | // Extract an SNS message as passed to Lambda. 2 | function getSnsMessage(lambdaEvent) { 3 | let message; 4 | 5 | try { 6 | message = JSON.parse(lambdaEvent.Records[0].Sns.Message); 7 | } catch (error) { 8 | throw new Error('Unable to extract SNS message'); 9 | } 10 | 11 | console.log('Received SNS message:', message); 12 | return message; 13 | } 14 | 15 | module.exports = getSnsMessage; 16 | -------------------------------------------------------------------------------- /lib/aws/lambda-invoke-function.js: -------------------------------------------------------------------------------- 1 | const Lambda = require('aws-sdk/clients/lambda'); 2 | 3 | const lambda = new Lambda(); 4 | 5 | function invokeLambdaFunction(payload, functionName, triggerAsync = false) { 6 | // Omitting the "Qualifier" property results in running "latest." 7 | const lambdaOptions = { 8 | FunctionName: functionName, 9 | Payload: JSON.stringify(payload), 10 | }; 11 | 12 | if (triggerAsync) { 13 | lambdaOptions.InvocationType = 'Event'; 14 | } 15 | 16 | return new Promise((resolve, reject) => { 17 | lambda.invoke(lambdaOptions, (error, data) => { 18 | if (error) { 19 | reject(error); 20 | return; 21 | } 22 | 23 | console.log('Received Lambda response....', data); 24 | 25 | try { 26 | resolve(JSON.parse(data.Payload)); 27 | } catch (parseError) { 28 | reject(new Error('Could not parse Lambda function response')); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | module.exports = invokeLambdaFunction; 35 | -------------------------------------------------------------------------------- /lib/aws/sns-publish-message.js: -------------------------------------------------------------------------------- 1 | const SNS = require('aws-sdk/clients/sns'); 2 | 3 | const sns = new SNS(); 4 | 5 | function publishSnsMessage(snsMessage, topicArn) { 6 | const payload = { 7 | Message: JSON.stringify(snsMessage), 8 | TopicArn: topicArn, 9 | }; 10 | 11 | return new Promise((resolve) => { 12 | sns.publish(payload, (error, data) => { 13 | if (error) { 14 | throw new Error(`Could not publish to ${topicArn}: ${error.message}`); 15 | } 16 | 17 | resolve(`OK: published SNS message ${data.MessageId} to ${topicArn}`); 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = publishSnsMessage; 23 | -------------------------------------------------------------------------------- /lib/readme.md: -------------------------------------------------------------------------------- 1 | # Library functions for AWS and Slack 2 | 3 | Copy this code as needed into your project. 4 | -------------------------------------------------------------------------------- /lib/slack/api/slack-app-verify-endpoint.js: -------------------------------------------------------------------------------- 1 | function verifyEndpoint(data) { 2 | return { challenge: data.challenge }; 3 | } 4 | 5 | module.exports = verifyEndpoint; 6 | -------------------------------------------------------------------------------- /lib/slack/messages/slack-reply-to-slash-command.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const url = require('url'); 3 | 4 | // This function requires the original message since using the provided 5 | // response_url is the only supported flow. 6 | function replyToSlashCommand(originalMessage, reply) { 7 | const postData = JSON.stringify(reply); 8 | const urlParts = url.parse(originalMessage.response_url); 9 | 10 | const options = { 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | 'Content-Length': Buffer.byteLength(postData), 14 | }, 15 | hostname: urlParts.host, 16 | method: 'POST', 17 | path: urlParts.path, 18 | port: urlParts.port || 443, 19 | }; 20 | 21 | return new Promise((resolve, reject) => { 22 | const req = https.request(options, (res) => { 23 | console.log(`OK: Slack responded with ${res.statusCode}`); 24 | resolve(); 25 | }); 26 | 27 | req.on('error', (err) => { 28 | console.log(`Slack responded with ${err.message}`); 29 | reject(err); 30 | }); 31 | 32 | req.write(postData); 33 | req.end(); 34 | }); 35 | } 36 | 37 | module.exports = replyToSlashCommand; 38 | -------------------------------------------------------------------------------- /lib/slack/messages/slack-send-message.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. Message can either be a simple string or a 6 | // JSON object with or without attachments. 7 | function sendToSlack(slackEvent, reply) { 8 | 9 | if (!reply) { 10 | console.log ('OK: nothing to say'); 11 | return; 12 | } 13 | 14 | console.log("Handling this event for sending back:", slackEvent); 15 | 16 | const slackMessage = { 17 | token: slackEvent.authorization.bot_access_token, 18 | channel: slackEvent.channel, 19 | }; 20 | 21 | // attachments must be URL encoded json 22 | if (reply.hasOwnProperty("attachments")) { 23 | slackMessage.attachments = JSON.stringify(reply.attachments); 24 | } 25 | 26 | if (reply.hasOwnProperty("text")) { 27 | slackMessage.text = reply.text; 28 | } 29 | 30 | if (typeof reply == 'string') { 31 | slackMessage.text = reply; 32 | } 33 | 34 | const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 35 | 36 | console.log("Prepared to send this to slack: ", requestUrl); 37 | 38 | // Send message. 39 | https.get(requestUrl, (res) => { 40 | console.log (`OK: responded, slack gave ${res.statusCode}`); 41 | return; 42 | }).on('error', (err) => { 43 | console.log (`ERROR: responded, but slack gave ${err.message}`); 44 | return; 45 | }); 46 | 47 | } 48 | 49 | module.exports = sendToSlack; 50 | -------------------------------------------------------------------------------- /oauth-dancer/index.js: -------------------------------------------------------------------------------- 1 | var request = require('request-promise'); 2 | var Sequelize = require('sequelize'); 3 | var TeamStore = require('./lib/models/db'); 4 | var crypto = require('crypto'); 5 | 6 | // Ask Slack to provide tokens for us to store and use later. 7 | var promiseToGetAuthorizationToken = function(code, options={}){ 8 | console.log("Starting Request..."); 9 | return request({ 10 | url: 'https://slack.com/api/oauth.access', 11 | method: 'POST', 12 | form: { 13 | client_id: (options.client_id || process.env.CLIENT_ID), 14 | client_secret: (options.client_secret || process.env.CLIENT_SECRET), 15 | code: code 16 | } 17 | }); 18 | }; 19 | 20 | var promiseToSaveAuthorization = function(responseString){ 21 | console.log("Received Request..."); 22 | var response = JSON.parse(responseString); 23 | console.log(JSON.stringify(response)); 24 | 25 | console.log("Looking up Team by slack_id: " + response.team_id); 26 | return db.Team.findOrCreate({ where: { slack_id: response.team_id } }).spread( 27 | (team, created) => { return db.Authorization.create({ team_id: team.id, details: response }); } 28 | ); 29 | }; 30 | 31 | // Sequelize needs to be instructed to close db connections 32 | // so that Lambda can exit gracefully 33 | var promiseToCloseConnections = function(){ 34 | db.sequelize.sync().then(function() { 35 | console.log("handles before:", process._getActiveHandles().length); 36 | return db.sequelize.close().then(function() { 37 | console.log("handles after:", process._getActiveHandles().length); 38 | }); 39 | }); 40 | }; 41 | 42 | var decodeState = function(stateString) { 43 | var decode = function(ciphertext, ivString){ 44 | 45 | var algorithm = "aes-256-cbc"; 46 | var iv = new Buffer(ivString, 'base64'); 47 | var key = new Buffer(keyString, 'base64'); 48 | var msgBuffer = new Buffer(ciphertext, 'base64'); 49 | 50 | var decipher = crypto.createDecipheriv(algorithm, key, iv); 51 | return decipher.update(ciphertext, 'base64', 'utf8') + decipher.final(); 52 | }; 53 | 54 | keyString = process.env.CIPHER_KEY; 55 | 56 | if (keyString) { 57 | var inputSplit = stateString.split("--"); 58 | var msgStr = inputSplit[0]; 59 | var ivStr = inputSplit[1]; 60 | 61 | var details = null; 62 | try { 63 | jsonStr = decode(msgStr, ivStr); 64 | details = JSON.parse(jsonStr); 65 | } catch(e) { 66 | console.log("Caught an error!"); 67 | console.log(e); 68 | } 69 | return details; 70 | } 71 | }; 72 | 73 | exports.handler = (event, context, callback) => { 74 | //console.log("\nENV: \n" + JSON.stringify(process.env) + "\n\n"); 75 | console.log("\nEvent: \n" + JSON.stringify(event) + "\n\n"); 76 | //console.log("\nContext: \n" + JSON.stringify(context) + "\n\n"); 77 | 78 | var success = function(){ 79 | console.log("Declaring Success"); 80 | callback(null, { 81 | statusCode: 200, 82 | body: "Hi! You've added Quackbot to your team!", 83 | isBase64Encoded: false 84 | }); 85 | }; 86 | 87 | var handleError = (failure) => { 88 | console.log("Encountered an Error"); 89 | console.log(failure); 90 | callback(new Error('internal server error')); 91 | }; 92 | 93 | var promiseToNotifyAdmin = function(authorization){ 94 | var hook = process.env.ADMIN_WEBHOOK; 95 | if (hook) { 96 | var userInfo = state ? `${state.email} (from ${state.slug})` : "Someone i couldn't identify"; 97 | 98 | return request({ 99 | url: hook, 100 | method: 'POST', 101 | json: { 102 | text: `${userInfo} just added me to ${authorization.details.team_name} (${authorization.details.team_id})!` 103 | } 104 | }); 105 | } 106 | }; 107 | 108 | var code = event.queryStringParameters.code; 109 | var state = decodeState(event.queryStringParameters.state); 110 | console.log("STATE IS: " + JSON.stringify(state)); 111 | 112 | db = new TeamStore(Sequelize); 113 | 114 | promiseToGetAuthorizationToken(code) 115 | .then(promiseToSaveAuthorization) 116 | .then(promiseToNotifyAdmin) 117 | .then(promiseToCloseConnections) 118 | .then(success, handleError); 119 | }; 120 | -------------------------------------------------------------------------------- /oauth-dancer/lib/config/config.sample.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "development": { 3 | "dialect": "postgres", 4 | "host": "localhost", 5 | "username": "quackbot", 6 | "password": "", 7 | "database": "quackbot_development" 8 | }, 9 | "test": { 10 | "dialect": "postgres", 11 | "host": "localhost", 12 | "username": "quackbot", 13 | "password": "", 14 | "database": "quackbot_test" 15 | }, 16 | "production": { 17 | "dialect": "postgres", 18 | "host": process.env.DB_HOSTNAME, 19 | "username": process.env.DB_USERNAME, 20 | "password": process.env.DB_PASSWORD, 21 | "database": process.env.DB_NAME, 22 | "logging": false 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /oauth-dancer/lib/migrations/20170926170020-create_teams.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.createTable('teams', { 6 | id: { 7 | allowNull: false, 8 | autoIncrement: true, 9 | primaryKey: true, 10 | type: Sequelize.INTEGER 11 | }, 12 | slack_id: { 13 | type: Sequelize.STRING, 14 | }, 15 | verified: { 16 | type: Sequelize.BOOLEAN, 17 | default: false 18 | }, 19 | verified_by: Sequelize.INTEGER, 20 | verified_at: Sequelize.DATE, 21 | created_at: { 22 | allowNull: false, 23 | type: Sequelize.DATE 24 | }, 25 | updated_at: { 26 | allowNull: false, 27 | type: Sequelize.DATE 28 | } 29 | }); 30 | }, 31 | 32 | 33 | down: (queryInterface, Sequelize) => { 34 | return queryInterface.dropTable('teams'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /oauth-dancer/lib/migrations/20170926170026-create_authorizations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.createTable('authorizations', { 6 | id: { 7 | allowNull: false, 8 | autoIncrement: true, 9 | primaryKey: true, 10 | type: Sequelize.INTEGER 11 | }, 12 | details: Sequelize.JSONB, 13 | created_at: { 14 | allowNull: false, 15 | type: Sequelize.DATE 16 | }, 17 | updated_at: { 18 | allowNull: false, 19 | type: Sequelize.DATE 20 | } 21 | }); 22 | }, 23 | 24 | down: (queryInterface, Sequelize) => { 25 | return queryInterface.dropTable('authorizations'); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /oauth-dancer/lib/migrations/20170928232847-add_team_id_to_authorizations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | /* 6 | Add altering commands here. 7 | Return a promise to correctly handle asynchronicity. 8 | 9 | Example: 10 | return queryInterface.createTable('users', { id: Sequelize.INTEGER }); 11 | */ 12 | return queryInterface.addColumn('authorizations', 'team_id', Sequelize.INTEGER, { 13 | allowNull: false 14 | }); 15 | }, 16 | 17 | down: (queryInterface, Sequelize) => { 18 | /* 19 | Add reverting commands here. 20 | Return a promise to correctly handle asynchronicity. 21 | 22 | Example: 23 | return queryInterface.dropTable('users'); 24 | */ 25 | return queryInterface.removeColumn('authorizations', 'team_id'); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /oauth-dancer/lib/models/authorization.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | var Authorization = sequelize.define('authorization', { 4 | id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, 5 | team_id: { type: DataTypes.INTEGER }, 6 | details: { type: DataTypes.JSONB }, 7 | }, { 8 | define: { timestamps: true }, 9 | underscored: true 10 | }); 11 | return Authorization; 12 | }; 13 | -------------------------------------------------------------------------------- /oauth-dancer/lib/models/db.js: -------------------------------------------------------------------------------- 1 | "use_strict"; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const basename = path.basename(module.filename); 6 | const env = process.env.NODE_ENV || 'development'; 7 | const config = require(__dirname + '/../config/config.js')[env]; 8 | 9 | var TeamStore = function(Sequelize, options) { 10 | 11 | this.sequelize = new Sequelize(options || config); 12 | var Team = require(__dirname+'/team')(this.sequelize, Sequelize); 13 | var Authorization = require(__dirname+'/authorization')(this.sequelize, Sequelize); 14 | 15 | Team.hasMany(Authorization); 16 | Authorization.belongsTo(Team); 17 | 18 | this.Team = Team; 19 | this.Authorization = Authorization; 20 | 21 | return this; 22 | }; 23 | 24 | TeamStore.prototype.close = function(){ 25 | return this.sequelize.sync().then(function() { 26 | console.log("handles before:", process._getActiveHandles().length); 27 | return this.sequelize.close().then(function() { 28 | console.log("handles after:", process._getActiveHandles().length); 29 | }); 30 | }); 31 | }; 32 | 33 | module.exports = TeamStore; -------------------------------------------------------------------------------- /oauth-dancer/lib/models/team.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | var Team = sequelize.define('team', { 4 | id: { 5 | type: DataTypes.INTEGER, 6 | primaryKey: true, 7 | autoIncrement: true 8 | }, 9 | slack_id: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | verified: { 14 | type: DataTypes.BOOLEAN, 15 | default: false 16 | }, 17 | verified_by: { type: DataTypes.INTEGER }, 18 | verified_at: { type: DataTypes.TIME }, 19 | }, { 20 | define: { timestamps: true }, 21 | underscored: true 22 | }); 23 | 24 | Team.prototype.latestAuthorization = function() { 25 | return this.getAuthorizations({ 26 | limit:1, 27 | order:[["created_at","desc"]] 28 | }); 29 | }; 30 | 31 | return Team; 32 | }; 33 | -------------------------------------------------------------------------------- /oauth-dancer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth-dancer", 3 | "version": "0.0.1", 4 | "description": "Slack oAuth callback handler", 5 | "main": "index.js", 6 | "scripts": { 7 | "sequelize": "./node_modules/.bin/sequelize --config lib/config/config.js --migrations-path lib/migrations --models-path lib/models" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/Quartz/quackbot.git" 12 | }, 13 | "author": "Ted Han", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/Quartz/quackbot/issues" 17 | }, 18 | "homepage": "https://github.com/Quartz/quackbot#readme", 19 | "dependencies": { 20 | "crypto": "^1.0.1", 21 | "pg": "^6.4.2", 22 | "request": "^2.82.0", 23 | "request-promise": "^4.2.2", 24 | "sequelize": "^4.11.1", 25 | "sequelize-cli": "^3.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /oauth-dancer/todo.md: -------------------------------------------------------------------------------- 1 | # oAuth Dancer 2 | 3 | This is a lambda function that's designed to catch an authorization callback 4 | from Slack. It'll respond to the request from Slack and initiate further 5 | calls to generate and validate authorization tokens. 6 | 7 | ## requirements 8 | 9 | * ✅ receive approval GET request from Slack w/ `code` and `state` parameters 10 | * ✅ [Initiate a request to slack for an access token](https://api.slack.com/docs/oauth#step_2_-_token_issuing) 11 | * Store the results of the authorization for later use. -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Quackbot](images/quackbot-icon_108.png) 2 | 3 | # Quackbot: A Slack bot for journalists 4 | 5 | [Quartz](https://qz.com) and [DocumentCloud](http://documentcloud.org) have teamed up to give journalists convenient access to tools that make their work easier, better, and a little more fun. Together we’re releasing Quackbot, which performs tasks useful to reporters, editors, and news producers right where so many of us work all day -- inside [Slack](http://slack.com). 6 | 7 | In its first version, Quackbot can do a select few tricks that might prove handy in a modern newsroom, from grabbing screenshots of webpages to pointing out clichés. But we’re excited to collaborate with the rest of the journalism world to give Quackbot many more skills over time. Think of it as a fully hosted and friendly interface to open-source tools. 8 | 9 | The [full announcement is here](https://bots.qz.com/2017/10/03/announcing-quackbot-a-slack-bot-for-journalists-from-quartz-and-documentcloud/). 10 | 11 | ## Just a duckling 12 | 13 | As of now, Quackbot can: 14 | 15 | - Can take a screenshot of any webpage. 16 | - Will preserve any URL by telling the Internet Archive to save a copy of the page. 17 | - Suggest some reliable sources of data. 18 | - Identify any cringe-worthy clichés on a web page, given a URL. 19 | 20 | Soon, Quackbot will also allow journalists to upload PDFs to DocumentCloud, extract text and charts from PDFs, monitor websites for changes, make quick charts, and more. We’re also inviting other journalists to bring their tools into Quackbot, making them readily available within Slack. If you’d like to add yours, please reach out to us at bots@qz.com. 21 | 22 | ## Installing 23 | 24 | Quackbot will be available starting this Thursday. All you’ll need is a DocumentCloud account (free for any journalist) and Slack. Add Quackbot to your team and, once the DocumentCloud team has verified you, we’ll activate it. That’s it. 25 | 26 | An installation link will appear here soon. In the meantime, if you're interested you can add your name and email to [this form](https://docs.google.com/forms/d/e/1FAIpQLSeSXJrqd-_uIaN8riPNsn1Wk66y8AtGQbuBLGSk6aLGicj3fQ/viewform?usp=sf_link). 27 | 28 | ## Using Quackbot 29 | 30 | Once added to a Slack team, people on the team can DM Quackbot. They can also start a message with `@quackbot` in any channel to which Quackbot has been invited. 31 | 32 | Use natural phrases and sentences to tap into Quackbot's talents. Like: 33 | 34 | - "Take a screenshot of qz.com" 35 | - "Save documentcloud.com to the internet archive" or "archive documentcloud.com" 36 | - "Find data about agriculture" 37 | 38 | ## Contributing 39 | 40 | If there's a tool or skill you think Quackbot should have, you can let us know at bots@qz.com. 41 | 42 | If you have a tool you've made that you'd like to incorporate, even better! Definitely get in touch. 43 | 44 | ## Privacy and security notes 45 | 46 | In short, Quackbot only "listens" to direct messages and channels to which it has been invited. And in those channels, it only pays attention to messages that begin with `@quackbot`. 47 | 48 | Here's the long version: 49 | 50 | Quackbot relies on Slack's [Events API](https://api.slack.com/events-api), which only sends messages that are in direct messages or channels in which Quackbot is a participant. 51 | 52 | In channels where Quackbot has been invited, messages that don't begin with `@quackbot` are ignored and are not written to our logs. 53 | 54 | Quackbot uses [AWS Lambda](https://aws.amazon.com/lambda/) serverless functions operated by DocumentCloud. Credentials that allow the bot to communicate with a team are stored in a Postgres database in AWS, also operated by DocumentCloud. 55 | 56 | Messages processed by Quackbot (direct messages addressed to the bot or channel messages beginning with `@quackbot`) are processed by the Lambda functions and are also relayed to [API.ai](http://api.ai) for natural language processing. While we work to keep the contents of user messages out of our logs, Amazon Web Services and API.ai may keep logs of user queries. 57 | 58 | All communications in transit are protected via [SSL](https://en.wikipedia.org/wiki/Transport_Layer_Security) using `https` calls. 59 | 60 | For transparency, all the code for all of Quackbot's functions is kept in public, in this repository. 61 | -------------------------------------------------------------------------------- /slack-events-api-bots/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/slack-events-api-bots/.DS_Store -------------------------------------------------------------------------------- /slack-events-api-bots/README.md: -------------------------------------------------------------------------------- 1 | # Quackbot's Bots 2 | 3 | Quackbot is really a bot of bots -- a single, friendly interface to a bunch of tools for journalists. 4 | 5 | This directory is where we keep the bots. 6 | 7 | ## Generally 8 | 9 | In general, Quackbot's sub-bots: 10 | 11 | - Exist as their own lambda functions on AWS. 12 | - Are deployed to AWS using `claudia.js` (at least that's what we do) 13 | - Contain their own data, node packages and utility functions 14 | - Receive the full Slack event for the message Quackbot got, along with some fields we've added along the way, including `event.command.predicate`, which contains information from the user that the bot will act upon -- like a url or a search topic. 15 | - Will be invoked by the router in `slack-events-api-message-handler`. 16 | 17 | ## Steps to set up 18 | 19 | These steps are what we follow, and some require AWS rights you probably don't have. Just get in touch if you'd like to add a bot! 20 | 21 | ### Prep the code 22 | 23 | - Make a new directory here, usually named for the verb/action used to invoke it 24 | - Include an `index.js` file 25 | - The bot's functionality is within a `index.js` function that looks like this: 26 | 27 | ``` 28 | exports.handler = function(event, context, callback){ 29 | // bot stuff here 30 | } 31 | ``` 32 | 33 | ### Make Lambda function using Claudia 34 | 35 | - Install Claudia using `npm install claudia --dev-save` 36 | - Make sure all the other packages are installed with `npm install` 37 | - In AWS, go to IAM Roles and make a separate lambda-executor role for the bot: 38 | - Service: Lambda 39 | - Permissions: log-writer (a customer-managed policy) 40 | - Name it like `quackbot-cliches-executor` 41 | - Now create the lambda function with claudia like this (replacing `cliches` with the sub-bot's name): 42 | ``` 43 | claudia create --region us-east-1 --handler index.handler --name quackbot-cliches --role quackbot-cliches-executor 44 | ``` 45 | - Note that if the bot is big, you may have to side-load it from an AWS bucket with `--use-s3-bucket [bucket_name]` 46 | - Commit the code to the `quackbot` repo 47 | 48 | ### Wire it to Quackbot 49 | 50 | - Edit `slack-events-api-message-handler/commands.js` to add to Quackbot's abilities. Note that the property label used ahead of the data must match the "action" supplied by the natural language processor. So here, it's `cliches`. 51 | ``` 52 | cliches: { 53 | type: 'lambda', 54 | functionName: 'quackbot-cliches', 55 | usage: 'Look for cliches on ', 56 | descrition: 'Scan a web page for cliches.' 57 | } 58 | ``` 59 | - Back at AWS under IAM Policies, add the bot's ARN as a "Resource" in the IAM policy called `quackbot-invoke-all-subbots` 60 | - Update the `slack-events-api-message-handler` using `claudia update` 61 | 62 | ### Prepare the Natural Language Processor 63 | 64 | - Train our [API.ai](http://API.ai) agent to send the bot's name as an ACTION and any additional parameters given a user's natural language input. So "please send me a screenshot of https://qz.com" becomes a "screenshot" action with "https://qz.com" as the url parameter. Note that the action must match the bot's property label in `slack-events-api-message-handler/commands.js`. (See example above using `cliches`.) 65 | - If the parameter provided by API.ai isn't "url" or "topic" you need to add the new type as a possible `event.command.predicate` in the code at `slack-events-api-message-handler/index.js` (TODO: Abstract this.) 66 | 67 | ### Test and deploy 68 | 69 | - Test in our test Slack account 70 | - Check the logs 71 | - Try again 72 | - Check the logs 73 | - Fix that thing 74 | - Check the logs 75 | - Deploy by pushing both the bot's lambda function and `slack-events-api-message-handler` to `prod` using `claudia set-version --version prod` 76 | 77 | 78 | -------------------------------------------------------------------------------- /slack-events-api-bots/archive/index.js: -------------------------------------------------------------------------------- 1 | const request = require('request'); 2 | const sendToSlack = require('./src/slack-send-message'); 3 | 4 | exports.handler = function(slackEvent, context, callback){ 5 | 6 | // funtional code goes here ... with the 'event' and 'context' coming from 7 | // whatever calls the lambda function (like CloudWatch or Alexa function). 8 | // callback function goes back to the caller. 9 | 10 | if (!slackEvent.command.predicate) { 11 | sendToSlack(slackEvent, "Oh, you have to specify a web page. Try `archive example.com`"); 12 | callback(null); 13 | return; 14 | } 15 | 16 | const website = slackEvent.command.predicate; 17 | 18 | const requestUrl = `https://web.archive.org/save/${website}`; 19 | 20 | request(requestUrl, function (error, response) { 21 | if (response.statusCode == 200) { 22 | console.log("Successfully saved wayback page"); 23 | sendToSlack(slackEvent, `Done! Saved ${website} to the internet archive.`); 24 | } else { 25 | console.log("Problem getting the wayback page: ", error, response); 26 | sendToSlack(slackEvent, "Hmmmm. That didn't work. Sometimes you just need to try again. Also be sure the URL is a good one."); 27 | } 28 | }); 29 | 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /slack-events-api-bots/archive/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "archive", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "claudia": "^2.14.2" 14 | }, 15 | "dependencies": { 16 | "request": "^2.83.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /slack-events-api-bots/archive/src/slack-send-message.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. Message can either be a simple string or a 6 | // JSON object with or without attachments. 7 | function sendToSlack(slackEvent, reply) { 8 | 9 | if (!reply) { 10 | console.log ('OK: nothing to say'); 11 | return; 12 | } 13 | 14 | console.log("Handling this event for sending back:", slackEvent); 15 | 16 | const slackMessage = { 17 | token: slackEvent.authorization.bot_access_token, 18 | channel: slackEvent.channel, 19 | }; 20 | 21 | // attachments must be URL encoded json 22 | if (reply.hasOwnProperty("attachments")) { 23 | slackMessage.attachments = JSON.stringify(reply.attachments); 24 | } 25 | 26 | if (reply.hasOwnProperty("text")) { 27 | slackMessage.text = reply.text; 28 | } 29 | 30 | if (typeof reply == 'string') { 31 | slackMessage.text = reply; 32 | } 33 | 34 | const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 35 | 36 | console.log("Prepared to send this to slack: ", requestUrl); 37 | 38 | // Send message. 39 | https.get(requestUrl, (res) => { 40 | console.log (`OK: responded, slack gave ${res.statusCode}`); 41 | return; 42 | }).on('error', (err) => { 43 | console.log (`ERROR: responded, but slack gave ${err.message}`); 44 | return; 45 | }); 46 | 47 | } 48 | 49 | module.exports = sendToSlack; 50 | -------------------------------------------------------------------------------- /slack-events-api-bots/archive/test.js: -------------------------------------------------------------------------------- 1 | var app = require('./index.js'); 2 | 3 | var send_to_app = { 4 | command: { 5 | predicate: "" 6 | } 7 | }; 8 | 9 | app.handler(send_to_app, null, function(error, result){ 10 | console.log(result); 11 | }); 12 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/README.md: -------------------------------------------------------------------------------- 1 | # Cliche Finder 2 | 3 | Given a URL, counts the cliches. 4 | 5 | ## Cliche files: 6 | 7 | - [681 Cliches to Avoid in Your Creative Writing](http://www.be-a-better-writer.com/cliches.html). 8 | - Washington Post's [The Outlook List of Things We Do Not Say](https://www.washingtonpost.com/news/opinions/wp/2014/02/27/the-outlook-list-of-things-we-do-not-say/?utm_term=.dbd630e45504) 9 | - [ProWritingAid](https://prowritingaid.com/art/21/List-of-Cliches.aspx) list of Cliches 10 | 11 | Also: 12 | https://www.npmjs.com/package/unfluff 13 | https://www.npmjs.com/package/fast-csv 14 | 15 | 16 | ## Setting Up Claudia 17 | 18 | ``` 19 | npm install claudia --save-dev 20 | ``` 21 | 22 | In this app, the bot runs in `index.js`. 23 | 24 | Note that "index.handler" below comes from the name of the file `index.js` and the module inside `exports.handler`. Other arguments listed [here]https://github.com/claudiajs/claudia/blob/master/docs/create.md. 25 | 26 | ``` 27 | ./node_modules/.bin/claudia create --region us-east-1 --handler index.handler --role lambda_basic_execution 28 | ``` 29 | 30 | ``` 31 | ./node_modules/.bin/claudia update 32 | ``` 33 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/data/681cliches.tsv: -------------------------------------------------------------------------------- 1 | a chip off the old block 2 | a clean slate 3 | a dark and stormy night 4 | a far cry 5 | a fine kettle of fish 6 | a (good|kind) soul 7 | a loose cannon 8 | a pain in the (neck|butt) 9 | a penny saved is a penny earned 10 | a tough row to hoe 11 | a word to the wise 12 | ace in the hole 13 | ace up his sleeve 14 | add insult to injury 15 | afraid of his own shadow 16 | against all odds 17 | air your dirty laundry 18 | all fun and games 19 | all in a day's work 20 | all talk, no action 21 | all thumbs 22 | all your eggs in one basket 23 | all's fair in love and war 24 | all's well that ends well 25 | almighty dollar 26 | American as apple pie 27 | an axe to grind 28 | another day, another dollar 29 | armed to the teeth 30 | as luck would have it 31 | as old as time 32 | as the crow flies 33 | at my wits end 34 | avoid like the plague 35 | babe in the woods 36 | back against the wall 37 | back in the saddle 38 | back to square one 39 | back to the drawing board 40 | bad to the bone 41 | badge of honor 42 | bald faced liar 43 | banging your head against a brick wall 44 | ballpark figure 45 | baptism by fire 46 | bark is worse than her bite 47 | barking up the wrong tree 48 | bat out of hell 49 | be all and end all 50 | beat a dead horse 51 | beat around the bush 52 | bee in her bonnet 53 | been there, done that 54 | beggars can't be choosers 55 | behind the eight ball 56 | bend over backwards 57 | benefit of the doubt 58 | bent out of shape 59 | best thing since sliced bread 60 | bet your bottom dollar 61 | better half 62 | better late than never 63 | better mousetrap 64 | better safe than sorry 65 | between a rock and a hard place 66 | beyond the pale 67 | bide your time 68 | big as life 69 | big fish in a small pond 70 | big cheese 71 | big man on campus 72 | bigger they are the harder they fall 73 | bird in the hand 74 | birds and the bees 75 | bird's eye view 76 | birds of a feather flock together 77 | bite the bullet 78 | bite the dust 79 | bit the hand that feeds you 80 | bitten off more than he can chew 81 | black as coal 82 | black as pitch 83 | black as the ace of spades 84 | blast from the past 85 | bleeding heart 86 | blessing in disguise 87 | blind ambition 88 | blind as a bat 89 | blind leading the blind 90 | blood is thicker than water 91 | blood sweat and tears 92 | blow off steam 93 | blow your own horn 94 | blushing bride 95 | boils down to 96 | bone to pick 97 | bored to tears 98 | bored stiff 99 | bottomless pit 100 | boys will be boys 101 | bright and early 102 | brings home the bacon 103 | broad across the beam 104 | broken record 105 | bull by the horns 106 | bull in a china shop 107 | burn the midnight oil 108 | burning the candle at both ends 109 | burning question 110 | burst your bubble 111 | bury the hatchet 112 | busy as a bee 113 | by hook or by crook 114 | call a spade a spade 115 | called onto the carpet 116 | calm before the storm 117 | can of worms 118 | can't cut the mustard 119 | can't hold a candle to 120 | case of mistaken identity 121 | cat got your tongue 122 | caught in the crossfire 123 | caught red-handed 124 | caught with his pants down 125 | caught with her pants down 126 | checkered past 127 | chip on (his|her) shoulder 128 | chomping at the bit 129 | cleanliness is next to godliness 130 | clear as a bell 131 | clear as mud 132 | close to the vest 133 | cock and bull story 134 | cold shoulder 135 | come hell or high water 136 | cost a king's ransom 137 | (cost|paid) an arm and a leg 138 | count your blessings 139 | crack of dawn 140 | crash course 141 | creature comforts 142 | cross that bridge when you come to it 143 | cry her eyes out 144 | cry like a baby 145 | cry me a river 146 | crystal clear 147 | curiosity killed the cat 148 | cut and dried 149 | cut through the red tape 150 | cut to the chase 151 | cute as a bugs ear 152 | cute as a button 153 | cute as a puppy 154 | cuts to the quick 155 | dark before the dawn 156 | day in, day out 157 | dead as a doornail 158 | devil is in the details 159 | dime a dozen 160 | divide and conquer 161 | dog and pony show 162 | dog days 163 | dog eat dog 164 | dog tired 165 | don't burn your bridges 166 | don't count your chickens before they're hatched 167 | don't look a gift horse in the mouth 168 | don't rock the boat 169 | don't step on anyone's toes 170 | don't take any wooden nickels 171 | down and out 172 | down at the heels 173 | down in the dumps 174 | down on (his|her) luck 175 | down the hatch 176 | down to earth 177 | draw the line 178 | dressed to kill 179 | dressed to the nines 180 | drives me up the wall 181 | dull as dishwater 182 | dyed in the wool 183 | eagle eye 184 | easy as pie 185 | eat your heart out 186 | eat your words 187 | enough to piss off the Pope 188 | ear to the ground 189 | early bird catches the worm 190 | earn (his|her) keep 191 | easier said than done 192 | easy as 1-2-3 193 | easy as pie 194 | eleventh hour 195 | even the playing field 196 | every dog has its day 197 | every fiber of my being 198 | everything but the kitchen sink 199 | eye for an eye 200 | eyes in the back of her head 201 | facts of life 202 | fair weather friend 203 | fan the flames 204 | fair weather friend 205 | fall by the wayside 206 | feast or famine 207 | feather in his cap 208 | feather your nest 209 | few and far between 210 | fifteen minutes of fame 211 | filthy vermin 212 | fine kettle of fish 213 | fish out of water 214 | fishing for a compliment 215 | fit as a fiddle 216 | fit the bill 217 | fit to be tied 218 | flat as a pancake 219 | flip your lid 220 | flog a dead horse 221 | fly by night 222 | fly the coop 223 | follow your heart 224 | for all intents and purposes 225 | for the birds 226 | for what it's worth 227 | force of nature 228 | force to be reckoned with 229 | forgive and forget 230 | fox in the henhouse 231 | free and easy 232 | free as a bird 233 | fresh as a daisy 234 | full steam ahead 235 | fun in the sun 236 | garbage in, garbage out 237 | get a kick out of 238 | get a leg up 239 | get down and dirty 240 | get (his|her) back up 241 | get the lead out 242 | get to the bottom of 243 | get your feet wet 244 | gets my goat 245 | gilding the lily 246 | give and take 247 | go against the grain 248 | go for broke 249 | go him one better 250 | go the extra mile 251 | go with the flow 252 | goes without saying 253 | good as gold 254 | good deed for the day 255 | good things come to those who wait 256 | good time was had by all 257 | greek to me 258 | green thumb 259 | green-eyed monster 260 | growing like a weed 261 | grist for the mill 262 | hair of the dog 263 | hand to mouth 264 | happy as a clam 265 | hasn't a clue 266 | have a nice day 267 | have high hopes 268 | haven't got a row to hoe 269 | have the last laugh 270 | head honcho 271 | hear a pin drop 272 | heard it through the grapevine 273 | heart's content 274 | hem and haw 275 | high and dry 276 | high and mighty 277 | high as a kite 278 | hit paydirt 279 | hold your horses 280 | hold your tongue 281 | hold your head up high 282 | hold your own 283 | honest as the day is long 284 | horse of a different color 285 | hot under the collar 286 | I beg to differ 287 | icing on the cake 288 | if the shoe fits 289 | if the shoe were on the other foot 290 | in a jam 291 | in a jiffy 292 | in a nutshell 293 | in a pig's eye 294 | in a pinch 295 | in a word 296 | in (his|her) element 297 | in hot water 298 | in over (his|her) head 299 | in the gutter 300 | in the nick of time 301 | in the thick of it 302 | in your dreams 303 | it ain't over till the fat lady sings 304 | it goes without saying 305 | it's a small world 306 | it's only a matter of time 307 | it takes all kinds 308 | it takes one to know one 309 | ivory tower 310 | Jack of all trades 311 | jockey for position 312 | jog your memory 313 | Johnny-come-lately 314 | joined at the hip 315 | judge a book by its cover 316 | jump down your throat 317 | jump in with both feet 318 | jump on the bandwagon 319 | jump the gun 320 | jump to conclusions 321 | just a hop, skip, and a jump 322 | just the ticket 323 | justice is blind 324 | keep a stiff upper lip 325 | keep an eye on 326 | keep it simple, stupid 327 | keep the home fires burning 328 | keep up with the Joneses 329 | keep your chin up 330 | keep your fingers crossed 331 | kick the bucket 332 | kick up your heels 333 | kick your feet up 334 | kid in a candy store 335 | kill two birds with one stone 336 | kick his lights out 337 | kick the bucket 338 | kiss of death 339 | knock his block off 340 | knock it out of the park 341 | knock on wood 342 | knock your socks off 343 | know him from Adam 344 | know the ropes 345 | know the score 346 | knuckle down 347 | knuckle sandwich 348 | knuckle under 349 | labor of love 350 | land on your feet 351 | lap of luxury 352 | last but not least 353 | last-ditch effort 354 | last hurrah 355 | law of the jungle 356 | law of the land 357 | lay down the law 358 | leaps and bounds 359 | let sleeping dogs lie 360 | letter perfect 361 | let the cat out of the bag 362 | let the good times roll 363 | let your hair down 364 | let's talk turkey 365 | lick your wounds 366 | lies like a rug 367 | life's a bitch 368 | life's a grind 369 | light at the end of the tunnel 370 | lighter than a feather 371 | lighter than air 372 | like clockwork 373 | like father like son 374 | like taking candy from a baby 375 | like there's no tomorrow 376 | lion's share 377 | live and learn 378 | live and let live 379 | long and short of it 380 | long lost love 381 | look before you leap 382 | look down your nose 383 | look what the cat dragged in 384 | looks like death warmed over 385 | loose cannon 386 | lose your head 387 | lose your temper 388 | loud as a horn 389 | lounge lizard 390 | loved and lost 391 | low man on the totem pole 392 | luck of the draw 393 | luck of the Irish 394 | make hay while the sun shines 395 | make money hand over fist 396 | make my day 397 | make the best of a bad situation 398 | make the best of it 399 | make your blood boil 400 | man of few words 401 | man's best friend 402 | mark my words 403 | missed the boat on that one 404 | moment in the sun 405 | moment of glory 406 | moment of truth 407 | money to burn 408 | more power to you 409 | more than one way to skin a cat 410 | movers and shakers 411 | naked as a jaybird 412 | naked truth 413 | neat as a pin 414 | needless to say 415 | neither here nor there 416 | never look back 417 | never say never 418 | nip and tuck 419 | nip it in the bud 420 | no guts, no glory 421 | no love lost 422 | no pain, no gain 423 | no skin off my back 424 | no stone unturned 425 | no time like the present 426 | no use crying over spilled milk 427 | nose to the grindstone 428 | not a hope in hell 429 | not a minute's peace 430 | not playing with a full deck 431 | not the end of the world 432 | not in my backyard 433 | not written in stone 434 | nothing to sneeze at 435 | nothing ventured nothing gained 436 | now we're cooking 437 | off the top of my head 438 | off the wagon 439 | off the wall 440 | older and wiser 441 | older than dirt 442 | older than Methuselah 443 | old hat 444 | on a roll 445 | on cloud nine 446 | on (his|her) high horse 447 | on pins and needles 448 | on the bandwagon 449 | on the money 450 | on the nose 451 | on the rocks 452 | on the spot 453 | on the tip of my tongue 454 | on the wagon 455 | on thin ice 456 | once bitten, twice shy 457 | one bad apple doesn't spoil the bushel 458 | one born every minute 459 | one brick short 460 | one foot in the grave 461 | one in a million 462 | one red cent 463 | only game in town 464 | open a can of worms 465 | open the flood gates 466 | opportunity doesn't knock twice 467 | over the hump 468 | out of pocket 469 | out of sight, out of mind 470 | out of the frying pan into the fire 471 | out of the woods 472 | out on a limb 473 | over a barrel 474 | pain and suffering 475 | panic button 476 | par for the course 477 | part and parcel 478 | party pooper 479 | pass the buck 480 | patience is a virtue 481 | pay through the nose 482 | penny pincher 483 | perfect storm 484 | pig in a poke 485 | pile it on 486 | pillar of the community 487 | pin your hopes on 488 | pitter patter of little feet 489 | plain as day 490 | plain as the nose on your face 491 | play by the rules 492 | play your cards right 493 | playing the field 494 | playing with fire 495 | pleased as punch 496 | plenty of fish in the sea 497 | poor as a church mouse 498 | pot calling the kettle black 499 | pull a fast one 500 | pull your punches 501 | pulled the wool over (his|her) eyes 502 | pulling your leg 503 | pure as the driven snow 504 | put one over on you 505 | put the pedal to the metal 506 | put the cart before the horse 507 | put your best foot forward 508 | put your foot down 509 | quick as a bunny 510 | quick as a lick 511 | quick as a wink 512 | quick as lightning 513 | quiet as a dormouse 514 | rags to riches 515 | raining buckets 516 | raining cats and dogs 517 | rank and file 518 | reap what you sow 519 | red as a beet 520 | red herring 521 | reinvent the wheel 522 | rich and famous 523 | rings a bell 524 | ripped me off 525 | rise and shine 526 | road to hell is paved with good intentions 527 | rob Peter to pay Paul 528 | roll over in the grave 529 | rub the wrong way 530 | running in circles 531 | salt of the earth 532 | scared out of (his|her) wits 533 | scared stiff 534 | scared to death 535 | sealed with a kiss 536 | second to none 537 | see eye to eye 538 | seen the light 539 | set the record straight 540 | set your teeth on edge 541 | sharp as a tack 542 | shoot the breeze 543 | shoot for the moon 544 | shot in the dark 545 | shoulder to the wheel 546 | sick as a dog 547 | seize the day 548 | sigh of relief 549 | signed, sealed, and delivered 550 | sink or swim 551 | six of one, half a dozen of another 552 | skating on thin ice 553 | slept like a log 554 | slinging mud 555 | slippery as an eel 556 | slow as molasses in January 557 | smooth as a baby's bottom 558 | snug as a bug in a rug 559 | sow wild oats 560 | spare the rod, spoil the child 561 | speak of the devil 562 | spilled the beans 563 | spinning your wheels 564 | spitting image of 565 | spoke with relish 566 | spring to life 567 | stands out like a sore thumb 568 | squeaky wheel gets the grease 569 | start from scratch 570 | stick in the mud 571 | still waters run deep 572 | stitch in time 573 | stop and smell the roses 574 | straw that broke the camel's back 575 | strong as an ox 576 | stubborn as a mule 577 | stuff that dreams are made of 578 | stuffed shirt 579 | sweating blood 580 | sweating bullets 581 | take a load off 582 | take one for the team 583 | take the bait 584 | take the bull by the horns 585 | take the plunge 586 | takes one to know one 587 | takes two to tango 588 | the more the merrier 589 | the real deal 590 | the real McCoy 591 | the red carpet treatment 592 | the same old story 593 | there is no accounting for taste 594 | thick as a brick 595 | thick as thieves 596 | think outside of the box 597 | third time's the charm 598 | this day and age 599 | this hurts me worse than it hurts you 600 | this point in time 601 | three sheets to the wind 602 | three strikes against (him|her) 603 | throw in the towel 604 | tie one on 605 | tighter than a drum 606 | time and time again 607 | time is of the essence 608 | tip of the iceberg 609 | to each his own 610 | to the best of my knowledge 611 | toe the line 612 | tongue-in-cheek 613 | too good to be true 614 | too hot to handle 615 | too numerous to mention 616 | touch with a ten foot pole 617 | tough as nails 618 | trials and tribulations 619 | tried and true 620 | trip down memory lane 621 | twist of fate 622 | two cents worth 623 | two peas in a pod 624 | ugly as sin 625 | under (his|her) thumb 626 | under the counter 627 | under the gun 628 | under the same roof 629 | until the cows come home 630 | unvarnished truth 631 | up his sleeve 632 | up the creek 633 | up to his ears in trouble 634 | uphill battle 635 | upper crust 636 | upset the applecart 637 | V for victory 638 | vain attempt 639 | vain effort 640 | vanquish the enemy 641 | vested interest 642 | waiting for the other shoe to drop 643 | wakeup call 644 | warm welcome 645 | watching the clock 646 | watch your p's and q's 647 | watch your tongue 648 | water under the bridge 649 | weather the storm 650 | went belly up 651 | wet behind the ears 652 | weed them out 653 | week of Sundays 654 | what goes around comes around 655 | what you see is what you get 656 | when it rains, it pours 657 | when push comes to shove 658 | when the cat's away 659 | when the going gets tough, the tough get going 660 | white as a sheet 661 | whole ball of wax 662 | whole hog 663 | whole nine yards 664 | wild goose chase 665 | will wonders never cease 666 | wisdom of the ages 667 | wolf at the door 668 | words fail me 669 | work like a dog 670 | world weary 671 | worst nightmare 672 | wrong side of the bed 673 | yanking your chain 674 | yappy as a dog 675 | years young 676 | you are what you eat 677 | you can run, but you can't hide 678 | you only live once 679 | young and foolish 680 | young and vibrant 681 | you're the boss 682 | only time will tell 683 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/data/quartz.tsv: -------------------------------------------------------------------------------- 1 | pulled the plug 2 | pull the plug 3 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/data/wapo.tsv: -------------------------------------------------------------------------------- 1 | At first (glance|blush) 2 | As a nation 3 | as a society 4 | Upon deeper reflection 5 | Observers 6 | \w+ is not alone 7 | And \w+ is no exception 8 | Pundits say 9 | Critics say 10 | critics are quick to point out 11 | The American people 12 | The narrative 13 | Probe 14 | (Opens|offers) a rare window 15 | Begs the question 16 | Be that as it may 17 | If you will 18 | A cautionary tale 19 | Cautiously optimistic 20 | Needless to say 21 | Suffice it to say 22 | This is not your father’s \w+ 23 | \w+ 2\.0\W 24 | \w+ 3\.0\W 25 | \w+ 4\.0\W 26 | At a crossroads 27 | powers that be 28 | Outside the box 29 | A favorite Washington parlor game 30 | Don’t get me wrong 31 | Make no mistake 32 | Yes, Virginia, there is a \w+ 33 | Christmas came early for 34 | Chock full 35 | Last-ditch effort 36 | Cue the \w+ 37 | Call it \w+ 38 | Pity the poor \w+ 39 | It’s the \w+, stupid 40 | ^Imagine 41 | Time will tell 42 | What a difference \w+ makes 43 | Palpable sense of relief 44 | Sigh of relief 45 | Plenty of blame to go around 46 | Rorschach test 47 | An object lesson 48 | Turned a blind eye 49 | Underscores 50 | Cycle of violence 51 | Searing indictment 52 | Potent symbol 53 | Broken system 54 | \w+ system is broken 55 | Famously 56 | The Other 57 | otherize 58 | otherization 59 | Shutter 60 | Gestalt 61 | Zeitgeist 62 | Orwellian 63 | Machiavellian 64 | Gladwellian 65 | What happens in \w+ stays in \w+ 66 | Oft-cited 67 | Little-noticed 68 | Closely watched 69 | Hastily convened 70 | Much ballyhooed 71 | ill-advised 72 | Shrouded in secrecy 73 | Since time immemorial 74 | Tipping point 75 | Inflection point 76 | Point of no return 77 | The [anything] community 78 | If history is any guide 79 | If past is prologue 80 | devil is in the details 81 | \w+ does not suffer fools gladly 82 | A ragtag army 83 | ragtag militia 84 | A tale of two \w+ 85 | Ignominious end 86 | Tightly knit 87 | In the final analysis 88 | At the end of the day 89 | For all intents and purposes 90 | Cooler heads prevailed 91 | Victim of (his|her) own success 92 | Punditocracy 93 | Twitterati 94 | Commentariat 95 | Chattering classes 96 | Naysayers 97 | Keen observer 98 | Took to Twitter 99 | Tongues wagging 100 | White-shoe law firm 101 | Well-heeled lobbyists 102 | Skittish donors 103 | Byzantine rules 104 | Strange bedfellows 105 | A mass of contradictions 106 | A land of contradictions 107 | Rise of the 24-hour news cycle 108 | In the digital age 109 | Not so fast 110 | Not so much 111 | Remains to be seen 112 | Tenuous at best 113 | Woefully inadequate 114 | Or so it seems 115 | Depending on whom you ask 116 | Burst onto the national political scene 117 | For now 118 | Tectonic shifts 119 | seismic shifts 120 | Optics 121 | Feeding frenzy 122 | feeding the frenzy 123 | Double down 124 | Game-changer 125 | In the wake of 126 | How I learned to stop worrying and love \w+ 127 | Love \w+ or hate \w+ 128 | The \w+ we love to hate 129 | Don the mantle of 130 | Usher in an era of 131 | A portrait emerges 132 | In a nutshell 133 | The social fabric 134 | the very fabric of our 135 | Hot-button issue 136 | Hotly contested 137 | Perfect storm 138 | Face-saving compromise 139 | Eye-popping 140 | The argument goes 141 | The thinking goes 142 | Contrary to popular belief 143 | Intoned 144 | The new normal 145 | The new face of 146 | The talk of the town 147 | It couple 148 | power couple 149 | Paradigm shift 150 | Unlikely revolutionary 151 | Unlikely reformer 152 | Grizzled veteran 153 | Manicured lawns 154 | Wide-ranging interview 155 | Rose from obscurity 156 | Dizzying array 157 | Withering criticism 158 | Predawn raid 159 | Nondescript office building 160 | Unsung hero 161 | Sparked debate 162 | Raised questions 163 | Raises more questions than answers 164 | Raise the specter of 165 | More often than not 166 | hand-wringing 167 | But (reality|truth) is more complicated 168 | Scarred by war 169 | War-torn 170 | War of words 171 | Trading barbs 172 | Shines a spotlight on 173 | is no panacea 174 | is no silver bullet 175 | Political football 176 | Political theater 177 | (More|less) than you think 178 | Not as much as you think 179 | You guessed it 180 | Shifting dynamics 181 | situation is fluid 182 | Partisans on both sides 183 | Charm offensive 184 | Fallen on hard times 185 | On thin ice 186 | A crisis waiting to happen 187 | Poster child 188 | Going forward 189 | Creature of Washington 190 | Official Washington 191 | A modest proposal 192 | Stinging rebuke 193 | Mr. \w+ goes to Washington 194 | The proverbial 195 | Fevered speculation 196 | Hope filled the air 197 | all the rage 198 | Iconic 199 | How did we get here 200 | But first, some background 201 | Growing body of evidence 202 | on steroids 203 | Resists easy (classification|categorization) 204 | Increasingly 205 | Tapped 206 | not un\w+ 207 | Wait for it -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/index.js: -------------------------------------------------------------------------------- 1 | var csv = require('fast-csv'); 2 | var fs = require('fs'); 3 | var request = require('request'); 4 | var unfluff = require('unfluff'); 5 | var sendToSlack = require('./src/slack-send-message'); 6 | 7 | var cliche_list; 8 | 9 | exports.handler = function(event, context, callback){ 10 | 11 | cliche_list = []; 12 | 13 | var url = event.command.predicate; 14 | // url validation done upstream 15 | 16 | request(url, function (request_error, response, body) { 17 | 18 | if (request_error) { 19 | console.log('error:', request_error); // Print the error if one occurred 20 | callback(request_error); 21 | return; 22 | } 23 | 24 | var page = unfluff(body); 25 | console.log(page.text); 26 | 27 | var allPromise = Promise.all([ 28 | checkDataFile('681cliches', page), 29 | checkDataFile('wapo', page), 30 | checkDataFile('prowritingaid', page), 31 | checkDataFile('quartz', page) 32 | ]); 33 | allPromise.then(function(){ 34 | // console.log("all checked"); 35 | var reply = replyWith(cliche_list); 36 | console.log(reply); 37 | sendToSlack(event, reply); 38 | callback(null, {} ); 39 | }) 40 | .catch(function(promise_err){ 41 | console.log(promise_err); 42 | callback(promise_err); 43 | }); 44 | 45 | }); 46 | 47 | }; 48 | 49 | function checkDataFile(file, page){ 50 | return new Promise(function (resolve, reject) { 51 | 52 | var stream = fs.createReadStream("data/" + file + ".tsv"); 53 | 54 | // set up listeners for every line in the CSV stream 55 | // assumes no headers in CSV 56 | csv 57 | .fromStream(stream, {delimiter: '\t'}) 58 | .on("data", function(data){ 59 | 60 | // data is an array, just need the first element data[0] 61 | // make a regular expression match out of the csv row 62 | var phrase = data[0].trim(); 63 | var pattern = new RegExp(phrase + "[ .?;:)!,-]", 'gi'); 64 | var matches = page.text.match(pattern); 65 | 66 | // if we have a match cliche and haven't found it 67 | // using another list already 68 | if (matches && cliche_list.indexOf(matches[0]) == -1 ) { 69 | 70 | // add the match string to the global list of cliches so far 71 | cliche_list.push(matches[0]); 72 | console.log("matched ", matches[0], " in ", file); 73 | 74 | } 75 | 76 | }) 77 | .on("end", function(){ 78 | // console.log(file + " done"); 79 | return resolve(); 80 | }) 81 | .on("error", function(error){ 82 | return reject(error); 83 | }); 84 | }); 85 | } 86 | 87 | function replyWith(the_list) { 88 | var message = {}; 89 | var total_cliches = the_list.length; 90 | 91 | message.text = "I found " + total_cliches + " cliches in the web page you gave me.\n"; 92 | 93 | if (total_cliches > 1) { 94 | message.text += "They are:\n"; 95 | 96 | message.attachments = [{}]; 97 | message.attachments[0].text = ""; 98 | 99 | for (var i = 0; i < total_cliches; i++) { 100 | 101 | message.attachments[0].text += the_list[i] + "\n"; 102 | 103 | } 104 | 105 | } 106 | 107 | if (the_list.length == 1) { 108 | message.text += "It's \"" + the_list[0] + ".\""; 109 | } 110 | 111 | return message; 112 | 113 | } 114 | 115 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botstudio-cliche-finder", 3 | "version": "1.0.0", 4 | "description": "Given a URL, counts the cliches.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/Quartz/botstudio-cliche-finder.git" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/Quartz/botstudio-cliche-finder/issues" 18 | }, 19 | "homepage": "https://github.com/Quartz/botstudio-cliche-finder#readme", 20 | "dependencies": { 21 | "fast-csv": "^2.4.0", 22 | "request": "^2.81.0", 23 | "unfluff": "^1.1.0" 24 | }, 25 | "devDependencies": { 26 | "claudia": "^2.12.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/src/slack-send-message.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. Message can either be a simple string or a 6 | // JSON object with or without attachments. 7 | function sendToSlack(slackEvent, reply) { 8 | 9 | if (!reply) { 10 | console.log ('OK: nothing to say'); 11 | return; 12 | } 13 | 14 | console.log("Handling this event for sending back:", slackEvent); 15 | 16 | const slackMessage = { 17 | token: slackEvent.authorization.bot_access_token, 18 | channel: slackEvent.channel, 19 | }; 20 | 21 | // attachments must be URL encoded json 22 | if (reply.hasOwnProperty("attachments")) { 23 | slackMessage.attachments = JSON.stringify(reply.attachments); 24 | } 25 | 26 | if (reply.hasOwnProperty("text")) { 27 | slackMessage.text = reply.text; 28 | } 29 | 30 | if (typeof reply == 'string') { 31 | slackMessage.text = reply; 32 | } 33 | 34 | const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 35 | 36 | console.log("Prepared to send this to slack: ", requestUrl); 37 | 38 | // Send message. 39 | https.get(requestUrl, (res) => { 40 | console.log (`OK: responded, slack gave ${res.statusCode}`); 41 | return; 42 | }).on('error', (err) => { 43 | console.log (`ERROR: responded, but slack gave ${err.message}`); 44 | return; 45 | }); 46 | 47 | } 48 | 49 | module.exports = sendToSlack; 50 | -------------------------------------------------------------------------------- /slack-events-api-bots/cliches/test.js: -------------------------------------------------------------------------------- 1 | var app = require('./index.js'); 2 | 3 | var url = "https://www.washingtonpost.com/news/opinions/wp/2014/02/27/the-outlook-list-of-things-we-do-not-say/?utm_term=.4a9cb67c9bef"; 4 | 5 | var lambda_blob = {}; 6 | lambda_blob.command = {}; 7 | lambda_blob.command.predicate = url; 8 | 9 | app.handler(lambda_blob, null, function(error, result){ 10 | // console.log(result); 11 | }); -------------------------------------------------------------------------------- /slack-events-api-bots/databot/NOTES.md: -------------------------------------------------------------------------------- 1 | # Process Notes for botstudio-search-sheet 2 | 3 | Here's where I keep the notes I take as I build. 4 | 5 | ## Concept 6 | 7 | Idea is to use Slack to search the information in [this spreadsheet](https://docs.google.com/spreadsheets/d/1hU7Snj4KZ-ppyy388l-sV4I26n4yGVb8xYnygPOS-5k/edit#gid=0). Or any spreadsheet. 8 | 9 | So in Slack: `/quack search-data agriculture` 10 | 11 | Should return some possibilities to pursue. 12 | 13 | Maybe the settings are: 14 | 15 | - url of spreadsheet to search 16 | - name of the sheet 17 | - columns to search 18 | - columns to reply with 19 | - format of reply? 20 | 21 | ## Setup 22 | 23 | Going to have this run as an AWS lambda function, so starting with my base lambda setup here: 24 | https://github.com/jkeefe/basic-lambda-setup 25 | 26 | 27 | ## Search 28 | 29 | Really excited to try [fuzzywuzzy](https://github.com/seatgeek/fuzzywuzzy) from SeatGeek. Or, really, the [Javacript port](https://github.com/nol13/fuzzball.js) of it. 30 | 31 | ``` 32 | npm init --yes 33 | npm install fuzzball --save 34 | ``` 35 | 36 | Also once again using the lovely [tabletopjs](https://github.com/jsoma/tabletop). 37 | 38 | ``` 39 | npm install tabletop --save 40 | ``` 41 | 42 | ## Put up on lambda 43 | 44 | Install claudia.js: 45 | 46 | ``` 47 | npm install claudia --save-dev 48 | ``` 49 | 50 | 51 | Create the lambda function. 52 | 53 | ``` 54 | ./node_modules/.bin/claudia create --region us-east-1 --handler index.handler --role lambda_basic_execution 55 | ``` 56 | 57 | Updating the lambda function: 58 | 59 | ``` 60 | ./node_modules/.bin/claudia update 61 | ``` 62 | 63 | ## Fielding queries from Quack bot 64 | 65 | Need to do the following: 66 | 67 | - add the file `lib/slack-reply-to-slash-command.js` to the project 68 | - add this line to `index.js`: 69 | 70 | `var replyToSlack = require('./lib/slack-reply-to-slash-command');` 71 | 72 | - in `index.js` replace the final callback with this: 73 | 74 | ``` 75 | replyToSlack(event, reply); 76 | callback(null, {} ); 77 | ``` 78 | 79 | -------------------------------------------------------------------------------- /slack-events-api-bots/databot/README.md: -------------------------------------------------------------------------------- 1 | # botstudio-search-sheet 2 | Search a Google spreadsheet from Slack 3 | -------------------------------------------------------------------------------- /slack-events-api-bots/databot/index.js: -------------------------------------------------------------------------------- 1 | var Tabletop = require('tabletop'); 2 | var fuzz = require('fuzzball'); 3 | var sendToSlack = require('./src/slack-send-message'); 4 | 5 | // The Google spreadsheet must be "Published" -- which is NOT the same as 6 | // sharing it publicly! (For reals.) Go to the spreadsheet then: 7 | // File > Publish to the Web ... 8 | // Use the URL you get once you've published it. 9 | var spreadsheet_published_url = "https://docs.google.com/spreadsheets/d/1hU7Snj4KZ-ppyy388l-sV4I26n4yGVb8xYnygPOS-5k/pub?gid=0&single=true&output=html"; 10 | 11 | // the name of the sheet in a mult-sheet spreadsheet 12 | var sheet_name = "Data"; 13 | 14 | // these named columns will be searched for a match 15 | var columns_to_search = ['Keywords', 'Product', 'Source', 'Topic']; 16 | 17 | // number of rows to slack back as examples 18 | var num_rows_to_send_back = 3; 19 | 20 | exports.handler = function(event, context, callback){ 21 | 22 | // funtional code goes here ... with the 'event' and 'context' coming from 23 | // whatever calls the lambda function (like CloudWatch or Alexa function). 24 | // callback function goes back to the caller. 25 | 26 | // our quack bot sends the info in command.predicate 27 | var query = event.command.predicate; 28 | 29 | getSpreadsheetData(spreadsheet_published_url, sheet_name) 30 | .then((sheet_data) => { 31 | searchSheet(query, sheet_data, columns_to_search) 32 | .then(makeSlackMessage) 33 | .then((message) => { 34 | sendToSlack(event, message); 35 | callback(null, {} ); 36 | }); 37 | }) 38 | .catch((promise_err) => { 39 | callback(promise_err); 40 | }); 41 | 42 | 43 | }; 44 | 45 | function getSpreadsheetData(document_URL, sheet_name) { 46 | return new Promise((resolve, reject) => { 47 | 48 | var options = { 49 | key: document_URL, 50 | callback: onLoad, 51 | simpleSheet: false 52 | }; 53 | 54 | function onLoad(data, tabletop) { 55 | 56 | // if (data == "" || data == null || data == undefined) { 57 | // reject("Error loading data"); 58 | // return; 59 | // } 60 | 61 | resolve(tabletop.sheets(sheet_name).all()); 62 | 63 | } 64 | 65 | Tabletop.init(options); 66 | 67 | }); 68 | } 69 | 70 | function searchSheet(search_query, sheet_data, search_columns) { 71 | return new Promise((resolve, reject) => { 72 | 73 | // gonna use batch-extract with multiple fields: 74 | // https://github.com/nol13/fuzzball.js#batch-extract 75 | 76 | var processor = function(choice) { 77 | var combination = ""; 78 | search_columns.forEach(function(field){ 79 | combination += choice[field] + " "; 80 | }); 81 | return combination; 82 | }; 83 | 84 | // set the fuzzy options 85 | var fuzz_options = { 86 | scorer: fuzz.partial_ratio, 87 | processor: processor, 88 | limit: num_rows_to_send_back, 89 | cutoff: 50, 90 | unsorted: false 91 | }; 92 | 93 | // // funciton used above for combining several columns 94 | // // into one thing to search 95 | 96 | 97 | // perform the fuzzy search 98 | var results = fuzz.extract(search_query, sheet_data, fuzz_options); 99 | 100 | console.log(results); 101 | resolve(results); 102 | 103 | }); 104 | } 105 | 106 | function makeSlackMessage(items) { 107 | 108 | var slack_message = {}; 109 | var attachments = []; 110 | 111 | if (items === undefined || items.length < 1 || !items) { 112 | 113 | slack_message.text = "Hmmmm ... I couldn't find any data sources matching that term in the list. Try again?"; 114 | return(slack_message); 115 | 116 | } 117 | 118 | for (var i = 0; i < items.length; i ++) { 119 | 120 | var item = items[i][0]; 121 | 122 | var attach = { 123 | "fallback": item['Product'] + " from " + item['Source'], 124 | "color": "#36a64f", 125 | "author_name": item['Source'], 126 | "title": item['Product'], 127 | "title_link": item['Product URL'], 128 | "text": "Coverage: " + item['Coverage'] + "\nGranularity: " + item['Granularity'] + "\n" 129 | }; 130 | 131 | // add fallback to the first item only 132 | if (i == 0) { 133 | attach.pretext = "Here are my top matching data sources. Explore the <" + spreadsheet_published_url + "| full source list here>."; 134 | } 135 | 136 | // if there are notes, add them to the text field 137 | if (item['Notes'] !== "") { 138 | attach.text += item['Notes']; 139 | } 140 | 141 | attachments.push(attach); 142 | 143 | } 144 | 145 | slack_message.attachments = attachments; 146 | return(slack_message); 147 | 148 | } 149 | -------------------------------------------------------------------------------- /slack-events-api-bots/databot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quack-search-sheet", 3 | "version": "1.0.0", 4 | "description": "Search a Google spreadsheet from Slack", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "John Keefe", 11 | "license": "ISC", 12 | "bugs": { 13 | "url": "https://github.com/Quartz/botstudio-search-sheet/issues" 14 | }, 15 | "homepage": "https://github.com/Quartz/botstudio-search-sheet#readme", 16 | "dependencies": { 17 | "fuzzball": "^0.12.2", 18 | "tabletop": "^1.5.2" 19 | }, 20 | "devDependencies": { 21 | "claudia": "^2.14.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /slack-events-api-bots/databot/src/slack-send-message.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. Message can either be a simple string or a 6 | // JSON object with or without attachments. 7 | function sendToSlack(slackEvent, reply) { 8 | 9 | if (!reply) { 10 | console.log ('OK: nothing to say'); 11 | return; 12 | } 13 | 14 | console.log("Handling this event for sending back:", slackEvent); 15 | 16 | const slackMessage = { 17 | token: slackEvent.authorization.bot_access_token, 18 | channel: slackEvent.channel, 19 | }; 20 | 21 | // attachments must be URL encoded json 22 | if (reply.hasOwnProperty("attachments")) { 23 | slackMessage.attachments = JSON.stringify(reply.attachments); 24 | } 25 | 26 | if (reply.hasOwnProperty("text")) { 27 | slackMessage.text = reply.text; 28 | } 29 | 30 | if (typeof reply == 'string') { 31 | slackMessage.text = reply; 32 | } 33 | 34 | const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 35 | 36 | console.log("Prepared to send this to slack: ", requestUrl); 37 | 38 | // Send message. 39 | https.get(requestUrl, (res) => { 40 | console.log (`OK: responded, slack gave ${res.statusCode}`); 41 | return; 42 | }).on('error', (err) => { 43 | console.log (`ERROR: responded, but slack gave ${err.message}`); 44 | return; 45 | }); 46 | 47 | } 48 | 49 | module.exports = sendToSlack; 50 | -------------------------------------------------------------------------------- /slack-events-api-bots/databot/test.js: -------------------------------------------------------------------------------- 1 | var app = require('./index.js'); 2 | 3 | var send_to_app = { 4 | command: { 5 | predicate: "agriculture" 6 | } 7 | }; 8 | 9 | app.handler(send_to_app, null, function(error, result){ 10 | console.log(JSON.stringify(result)); 11 | }); 12 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/slack-events-api-bots/screenshot/.DS_Store -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/config.js: -------------------------------------------------------------------------------- 1 | const port = 9222; 2 | 3 | const chromeFlags = [ 4 | '--headless', 5 | '--disable-gpu', 6 | '--no-sandbox', 7 | '--user-data-dir=/tmp/user-data', 8 | '--hide-scrollbars', 9 | '--enable-logging', 10 | '--log-level=0', 11 | '--v=99', 12 | '--single-process', 13 | '--data-path=/tmp/data-path', 14 | `--remote-debugging-port=${port}`, 15 | '--ignore-certificate-errors', 16 | '--homedir=/tmp', 17 | '--disk-cache-dir=/tmp/cache-dir', 18 | ]; 19 | 20 | const chrome = { 21 | headlessPort: port, 22 | headlessUrl: `http://127.0.0.1:${port}`, 23 | pageLoadTimeout: 1000 * 60, 24 | path: '/tmp/headless-chrome/headless_shell', 25 | startupTimeout: 1000 * 10, 26 | }; 27 | 28 | const customizations = { 29 | 'twitter.com': { 30 | cropElement: '.permalink-tweet', 31 | stylesheet: 'inject/css/twitter.css', 32 | } 33 | }; 34 | 35 | const s3 = { 36 | bucket: 'quack-screenshots', 37 | }; 38 | 39 | // `fromSurface: true` is needed on OS X. 40 | const screenshot = { 41 | format: 'png', 42 | timeout: 5000, 43 | }; 44 | 45 | module.exports = { 46 | chromeFlags, 47 | chrome, 48 | customizations, 49 | s3, 50 | screenshot, 51 | }; 52 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/index.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const lambdaChrome = require('lambda-chrome'); 3 | const captureScreenshot = require('./src/capture-screenshot'); 4 | const sendToSlack = require('./src/slack-send-message'); 5 | 6 | function generateReply(s3Response, slackEvent) { 7 | return { 8 | attachments: [ 9 | { 10 | "text": "Ding! Your screenshot is ready. Enjoy.", 11 | "fallback": "Here's your generated screenshot", 12 | "image_url": `http://${config.s3.bucket}.s3-website-us-east-1.amazonaws.com/${s3Response.key}`, 13 | "footer": `Taken from ${slackEvent.command.predicate}`, 14 | }, 15 | ], 16 | }; 17 | } 18 | 19 | exports.handler = function (slackEvent, context, callback) { 20 | console.log('Received Slack event....', slackEvent); 21 | 22 | if (!slackEvent.command.predicate) { 23 | // missing URLs actually handled upstream by API.ai 24 | // sendToSlack(slackEvent, "Oh, you have to specify a website. Try `@quackbot screenshot example.com`"); 25 | callback(null); 26 | return; 27 | } 28 | 29 | const url = slackEvent.command.predicate; 30 | 31 | lambdaChrome() 32 | .then(client => captureScreenshot(client, url)) 33 | .then(s3Response => { 34 | console.log('Generated screenshot....', s3Response); 35 | return sendToSlack(slackEvent, generateReply(s3Response, slackEvent)); 36 | }) 37 | .then(() => { 38 | callback(null, 'Responded to Slack.'); 39 | }) 40 | .catch(err => { 41 | console.error(err); 42 | sendToSlack(slackEvent, "Hmmm. Something went awry there."); 43 | callback(null); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/inject/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/slack-events-api-bots/screenshot/inject/.DS_Store -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quackbot-screenshot", 3 | "version": "1.0.0", 4 | "description": "quack screenshot", 5 | "main": "index.js", 6 | "author": "Chris Zarate", 7 | "license": "ISC", 8 | "dependencies": { 9 | "aws-sdk": "^2.52.0", 10 | "gm": "^1.23.0", 11 | "lambda-chrome": "chriszarate/aws-lambda-headless-chrome" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/capture-screenshot.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const gm = require('gm').subClass({ imageMagick: true }); 3 | 4 | const config = require('../config'); 5 | const cropScreenshot = require('./crop-screenshot'); 6 | const getElementRect = require('./get-element-rect'); 7 | const injectStylesheet = require('./inject-stylesheet'); 8 | const saveImage = require('./save-image'); 9 | const { delay } = require('./utils'); 10 | 11 | // Set up viewport resolution, etc. 12 | const deviceMetrics = { 13 | width: 1200, 14 | height: 1000, 15 | deviceScaleFactor: 2, 16 | mobile: false, 17 | fitWindow: true, 18 | }; 19 | 20 | function saveToS3(buffer, url) { 21 | // Generate hash suffix for filename. 22 | const hash = crypto.createHmac('sha512', '41ecde00bfbe5b8ca3b82601b749bdf3'); 23 | hash.update(url); 24 | 25 | const s3Params = { 26 | ACL: 'public-read', 27 | Body: buffer, 28 | Bucket: config.s3.bucket, 29 | ContentType: 'image/jpeg', 30 | Key: `screenbot/${hash.digest('hex').substr(0, 16)}.jpg`, 31 | }; 32 | 33 | console.log('Saving screenshot to S3....'); 34 | return saveImage(s3Params); 35 | } 36 | 37 | function getCustomizations(url) { 38 | const key = Object.keys(config.customizations).find(domain => url.indexOf(domain) !== -1); 39 | return config.customizations[key] || {}; 40 | } 41 | 42 | function convertScreenshot(buffer) { 43 | console.log('Converting to JPEG....'); 44 | 45 | return new Promise((resolve, reject) => { 46 | gm(buffer).toBuffer('jpg', (error, newBuffer) => { 47 | if (error) { 48 | console.error('Error converting to JPEG....', error); 49 | reject(error); 50 | return; 51 | } 52 | 53 | resolve(newBuffer); 54 | }); 55 | }); 56 | } 57 | 58 | function captureScreenshot(client, url) { 59 | const customizations = getCustomizations(url); 60 | 61 | return new Promise((resolve, reject) => { 62 | const { Emulation, Page, Runtime } = client; 63 | const timeout = setTimeout(reject, config.chrome.pageLoadTimeout); 64 | 65 | const doCrop = (buffer) => { 66 | if (!customizations.cropElement) { 67 | return buffer; 68 | } 69 | 70 | return Runtime.evaluate({ 71 | expression: getElementRect(customizations.cropElement), 72 | returnByValue: true, 73 | }) 74 | .then(response => cropScreenshot(buffer, response)) 75 | .catch(() => Promise.resolve(buffer)); 76 | }; 77 | 78 | const doInjection = () => { 79 | if (!customizations.stylesheet) { 80 | return Promise.resolve(); 81 | } 82 | 83 | return Runtime.evaluate({ 84 | expression: injectStylesheet(customizations.stylesheet), 85 | }); 86 | }; 87 | 88 | const getScreenshotBuffer = () => { 89 | console.log('Taking screenshot....'); 90 | return Page.captureScreenshot(config.screenshot).then(screenshot => Buffer.from(screenshot.data, 'base64')); 91 | }; 92 | 93 | const saveScreenshot = buffer => saveToS3(buffer, url).then((s3Response) => { 94 | client.close().then(() => { 95 | clearTimeout(timeout); 96 | resolve(s3Response); 97 | }); 98 | }); 99 | 100 | Page.loadEventFired(() => { 101 | delay(1000)() 102 | .then(doInjection) 103 | .then(delay(config.screenshot.timeout)) 104 | .then(getScreenshotBuffer) 105 | .then(doCrop) 106 | .then(convertScreenshot) 107 | .then(saveScreenshot); 108 | }); 109 | 110 | [ 111 | Page.enable(), 112 | Runtime.enable(), 113 | Emulation.setDeviceMetricsOverride(deviceMetrics), 114 | Emulation.setVisibleSize({ width: deviceMetrics.width, height: deviceMetrics.height }), 115 | Page.navigate({ url }), 116 | ].reduce((p, fn) => p.then(fn), Promise.resolve()); 117 | }); 118 | } 119 | 120 | module.exports = captureScreenshot; 121 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/crop-screenshot.js: -------------------------------------------------------------------------------- 1 | const gm = require('gm').subClass({ imageMagick: true }); 2 | 3 | function cropScreenshot(buffer, response) { 4 | const rect = response.result.value; 5 | 6 | console.log('Cropping screenshot....'); 7 | return new Promise((resolve, reject) => { 8 | gm(buffer) 9 | .crop(rect.width, rect.height, rect.left, rect.top) 10 | .toBuffer('png', (error, newBuffer) => { 11 | if (error) { 12 | console.error('Error cropping screenshot....', error); 13 | reject(error); 14 | return; 15 | } 16 | 17 | resolve(newBuffer); 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = cropScreenshot; 23 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/get-element-rect.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const inject = (selector) => { 4 | const el = document.querySelector(selector); 5 | const rect = el ? el.getBoundingClientRect() : {}; 6 | 7 | return { 8 | bottom: rect.bottom, 9 | height: rect.height, 10 | left: rect.left, 11 | right: rect.right, 12 | top: rect.top, 13 | width: rect.width, 14 | }; 15 | }; 16 | 17 | function getElementRect(selector) { 18 | console.log(`Getting bounding rect for ${selector}....`); 19 | return `(${inject.toString()})('${selector}')`; 20 | } 21 | 22 | module.exports = getElementRect; 23 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/inject-stylesheet.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const inject = (cssString) => { 7 | const addTo = (doc) => { 8 | const style = doc.createElement('style'); 9 | style.type = 'text/css'; 10 | style.innerHTML = cssString; 11 | 12 | doc.querySelector('head').appendChild(style); 13 | }; 14 | 15 | addTo(document); 16 | 17 | const cardFrame = document.querySelector('.card2 iframe'); 18 | if (cardFrame) { 19 | console.log('Injecting in Twitter card frame....'); 20 | addTo(cardFrame.contentDocument); 21 | } 22 | }; 23 | 24 | function injectStylesheet(stylesheet) { 25 | console.log(`Injecting stylesheet ${stylesheet}....`); 26 | 27 | const data = fs.readFileSync(path.resolve(stylesheet)); 28 | const cssString = data.toString().replace(/\n/g, ''); 29 | const injectString = inject.toString().replace(/\n/g, ''); 30 | 31 | return `(${injectString})('${cssString}')`; 32 | } 33 | 34 | module.exports = injectStylesheet; 35 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/save-image.js: -------------------------------------------------------------------------------- 1 | const S3 = require('aws-sdk/clients/s3'); 2 | 3 | function saveImage(s3Params) { 4 | const s3 = new S3(); 5 | 6 | return new Promise((resolve, reject) => { 7 | s3.putObject(s3Params, (error, data) => { 8 | if (error) { 9 | reject(error); 10 | return; 11 | } 12 | 13 | resolve({ 14 | etag: data.ETag, 15 | key: s3Params.Key, 16 | }); 17 | }); 18 | }); 19 | } 20 | 21 | module.exports = saveImage; 22 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/slack-send-message.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. Message can either be a simple string or a 6 | // JSON object with or without attachments. 7 | function sendToSlack(slackEvent, reply) { 8 | 9 | if (!reply) { 10 | console.log ('OK: nothing to say'); 11 | return; 12 | } 13 | 14 | console.log("Handling this event for sending back:", slackEvent); 15 | 16 | const slackMessage = { 17 | token: slackEvent.authorization.bot_access_token, 18 | channel: slackEvent.channel, 19 | }; 20 | 21 | // attachments must be URL encoded json 22 | if (reply.hasOwnProperty("attachments")) { 23 | slackMessage.attachments = JSON.stringify(reply.attachments); 24 | } 25 | 26 | if (reply.hasOwnProperty("text")) { 27 | slackMessage.text = reply.text; 28 | } 29 | 30 | if (typeof reply == 'string') { 31 | slackMessage.text = reply; 32 | } 33 | 34 | const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 35 | 36 | console.log("Prepared to send this to slack: ", requestUrl); 37 | 38 | // Send message. 39 | https.get(requestUrl, (res) => { 40 | console.log (`OK: responded, slack gave ${res.statusCode}`); 41 | return; 42 | }).on('error', (err) => { 43 | console.log (`ERROR: responded, but slack gave ${err.message}`); 44 | return; 45 | }); 46 | 47 | } 48 | 49 | module.exports = sendToSlack; 50 | -------------------------------------------------------------------------------- /slack-events-api-bots/screenshot/src/utils.js: -------------------------------------------------------------------------------- 1 | function delay(timeout) { 2 | return value => new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve(value); 5 | }, timeout); 6 | }); 7 | } 8 | 9 | module.exports = { 10 | delay, 11 | }; 12 | -------------------------------------------------------------------------------- /slack-events-api-gateway/index.js: -------------------------------------------------------------------------------- 1 | const ApiBuilder = require('claudia-api-builder'); 2 | const postMessage = require('./routes/messages-post'); 3 | 4 | const api = new ApiBuilder(); 5 | 6 | api.post('/messages', postMessage.bind(null, api)); 7 | 8 | module.exports = api; 9 | -------------------------------------------------------------------------------- /slack-events-api-gateway/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quackbot-slack-events-api-router", 3 | "version": "1.0.0", 4 | "description": "Routes events from Slack Events API to other Lambda functions", 5 | "main": "index.js", 6 | "author": "Chris Zarate", 7 | "license": "ISC", 8 | "dependencies": { 9 | "aws-sdk": "^2.45.0", 10 | "claudia-api-builder": "^2.4.1" 11 | }, 12 | "devDependencies": { 13 | "claudia": "^2.14.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /slack-events-api-gateway/readme.md: -------------------------------------------------------------------------------- 1 | # Slack Events to AWS Lambda 2 | 3 | This repo provides a Slack Events API endpoint on AWS API Gateway and Lambda 4 | to process Slack events. It parses the response and either invokes another 5 | Lambda function or responds to let the user know that something went wrong. 6 | 7 | ## Creating and updating the endpoint 8 | 9 | ``` 10 | claudia create --api-module index --region us-east-1 11 | ``` 12 | 13 | [Claudia][claudia] will print out the endpoint base URL. Append `/messages` to 14 | the end and provide it to Slack in the "Events" section of your app 15 | configuration. 16 | 17 | Update the endpoint with: 18 | 19 | ``` 20 | claudia update 21 | ``` 22 | 23 | ## Add a verification token as stage variable 24 | 25 | In the AWS web console, select your endpoint, select the stage, open the "Stage 26 | Variables" tab, and add a variable named `slackAppVerificationToken`. The value 27 | should be your app's verification token, which can be found on the "Basic 28 | Information" page of your app's settings. 29 | 30 | Or run the following AWS CLI command (using the `jq` utility to extract the API 31 | Gateway ID): 32 | 33 | ``` 34 | aws apigateway create-deployment \ 35 | --rest-api-id $(jq -r '.api.id' claudia.json) \ 36 | --stage-name latest \ 37 | --variables slackAppVerificationToken=mytoken 38 | ``` 39 | 40 | ## Enabling commands 41 | 42 | This endpoint expects to receive messages in a specific format: 43 | 44 | ``` 45 | command verb predicate which can be zero or more words 46 | ``` 47 | 48 | Add your verb to `commands.js`. 49 | 50 | ## That's it? 51 | 52 | That's it. But now you can write any number of Lambda functions or other code 53 | to respond to these commands. 54 | 55 | ## Using API Gateway stages 56 | 57 | One way to manage testing—for both this router and the Lambda functions it 58 | calls—is to create different "stages" on a single API Gateway endpoint and 59 | provide them to two different Slack teams: a "testing" Slack team and a "prod" 60 | Slack team. This allows you to avoid multiple separate deployments of your 61 | Lambda functions. 62 | 63 | When you initially created your endpoint, your one and only stage is named 64 | "latest" (the default). Notice that the stage name is included in the endpoint 65 | URL. Provide this endpoint to a "testing" Slack team. This is your dev tier. 66 | 67 | Create a new stage named "prod": 68 | 69 | ``` 70 | claudia set-version --version prod 71 | ``` 72 | 73 | This is your prod tier, which will invoke the "prod" qualifier / alias of your 74 | router Lambda function (which the above command also created). You also must 75 | provide the verification token from your "prod" Slack team as a stage variable, 76 | as outlined in the section "Add a verification token as stage variable" above 77 | (the token is different for each Slack team). 78 | 79 | Using `claudia set-version`, you will also need to create the "prod" alias for 80 | each Lambda function you wish to invoke via the router. The pattern will be 81 | consistent: The "latest" stage of the API Gateway invokes the "latest" version 82 | of the router function, which invokes the "latest" version command functions. 83 | Similarly, the "prod" stage invokes the "prod" versions of the router and 84 | command functions. This allows you to run bleeding-edge code in your "testing" 85 | Slack team and reliable code in your "prod" Slack team. 86 | 87 | Use the same flow for updating *both the router and command functions*: 88 | 89 | 1. Update your code. 90 | 91 | 2. Run `claudia update`, which automatically updates the "latest" alias. 92 | 93 | 3. Test in your "testing" Slack. 94 | 95 | 4. Run `claudia set-version --version prod` to update the "prod" alias. 96 | 97 | [app-config]: https://api.slack.com/slack-apps 98 | [claudia]: https://claudiajs.com 99 | 100 | ## Repo Note 101 | 102 | Note that while this directory is called `slack-events-api-gateway` -- which is an accurate description -- the lambda function it represents is still called `slack-events-api-router`, which is not what it does anymore. 103 | 104 | 105 | -------------------------------------------------------------------------------- /slack-events-api-gateway/routes/messages-post.js: -------------------------------------------------------------------------------- 1 | const invokeLambdaFunction = require('../src/lambda-invoke-function'); 2 | 3 | const supportedEventTypes = [ 4 | 'message', 5 | 'message.channels' 6 | ]; 7 | 8 | function route(api, request) { 9 | return new Promise(resolve => { 10 | if (typeof request.body !== 'object') { 11 | throw new Error('Unexepcted request format.'); 12 | } 13 | 14 | // Slack sends a verification token with each request. We use this to verify 15 | // that the message is really coming from Slack and not someone else that 16 | // found our endpoint. The verification token is different for each 17 | // instance of Slack and can be found on the "Basic Information" page of the 18 | // app settings. 19 | if (request.body.token !== request.env.slackAppVerificationToken) { 20 | console.log('Invalid app verification token.'); 21 | resolve(); 22 | return; 23 | } 24 | 25 | // Slack asks us to verify the endpoint (once). 26 | if (request.body.type === 'url_verification') { 27 | console.log('Responding to Slack URL verification challenge....'); 28 | resolve({ challenge: request.body.challenge }); 29 | return; 30 | } 31 | 32 | if (request.body.type !== 'event_callback' || typeof request.body.event !== 'object') { 33 | console.log(`Unexpected event type: ${request.body.type}`); 34 | resolve(); 35 | return; 36 | } 37 | 38 | // Event subscriptions are managed in the Slack App settings. 39 | if (supportedEventTypes.indexOf(request.body.event.type) === -1) { 40 | console.log(`Unsupported event type: ${request.body.event.type}`); 41 | resolve(); 42 | return; 43 | } 44 | 45 | // Skip altered messages for now to avoid bot confusion 46 | // Later, to allow file uploads, use next line instead 47 | // if (request.body.event.hasOwnProperty('subtype') && request.body.event.subtype != 'file_share') { 48 | if (request.body.event.hasOwnProperty('subtype')) { 49 | console.log(`Subtype of "${request.body.event.subtype}" suggests a modified message. Skipping.`); 50 | resolve(); 51 | return; 52 | } 53 | 54 | // Don't respond to other bots. 55 | if (request.body.event.bot_id) { 56 | console.log('Ignoring message from myself or a fellow bot, bye!'); 57 | resolve(); 58 | return; 59 | } 60 | 61 | // Add API Gateway stage to message. We'll need this to determine where to 62 | // route the message. 63 | console.log(`Incoming event sent to ${request.context.stage} stage.`); 64 | request.body.event.stage = request.context.stage; 65 | 66 | // Also add the stage's environment variables to the message so 67 | // we use the right database and all 68 | request.body.event.env = request.env; 69 | request.body.event.team_id = request.body.team_id; 70 | 71 | // Invoke router Lambda function. 72 | resolve(invokeLambdaFunction(request.body.event, 'slack-events-api-message-handler')); 73 | }) 74 | .then((response) => { 75 | // We should respond to Slack with 200 to indicate that we've received the 76 | // event. If we do not, Slack will retry three times with back-off. 77 | console.log("Said 'OK' to Slack."); 78 | return response || new api.ApiResponse('OK', { 'Content-Type': 'text/plain' }, 200); 79 | }) 80 | .catch(error => { 81 | // We should *still* respond to Slack with 200, we'll just log it. 82 | console.error("Error caught in slack-events-api-gateway:", error); 83 | return new api.ApiResponse('OK', { 'Content-Type': 'text/plain' }, 200); 84 | }); 85 | } 86 | 87 | module.exports = route; 88 | -------------------------------------------------------------------------------- /slack-events-api-gateway/src/lambda-invoke-function.js: -------------------------------------------------------------------------------- 1 | const Lambda = require('aws-sdk/clients/lambda'); 2 | 3 | const lambda = new Lambda(); 4 | 5 | function invokeLambdaFunction(payload, functionName) { 6 | const lambdaOptions = { 7 | FunctionName: functionName, 8 | InvocationType: 'Event', 9 | Payload: JSON.stringify(payload), 10 | }; 11 | 12 | // Omitting the Qualifier property results in running the $LATEST version. 13 | if (payload.stage !== 'latest') { 14 | lambdaOptions.Qualifier = payload.stage; 15 | } 16 | 17 | return new Promise((resolve, reject) => { 18 | lambda.invoke(lambdaOptions, (error, data) => { 19 | if (error) { 20 | reject(error); 21 | return; 22 | } 23 | 24 | resolve(); 25 | }); 26 | }); 27 | } 28 | 29 | module.exports = invokeLambdaFunction; 30 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/commands.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | screenshot: { 3 | type: 'lambda', 4 | functionName: 'quackbot-screenshot', 5 | usage: 'screenshot www.example.com', 6 | description: 'Grabs a screenshot of a website and Slacks it at you.', 7 | }, 8 | data: { 9 | type: 'lambda', 10 | functionName: 'quack-search-sheet', 11 | usage: 'data agriculture', 12 | description: 'Searches Christopher Groskopf\'s spreadsheet of good data sources.', 13 | }, 14 | archive: { 15 | type: 'lambda', 16 | functionName: 'quackbot-archive-bot', 17 | usage: 'archive ', 18 | description: 'Save a URL to the Internet Archive.', 19 | }, 20 | cliches: { 21 | type: 'lambda', 22 | functionName: 'quackbot-cliches', 23 | usage: 'Look for cliches on ', 24 | descrition: 'Scan a web page for cliches.' 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/index.js: -------------------------------------------------------------------------------- 1 | const respondOnError = require('./src/respond-on-error'); 2 | const routeMessage = require('./src/route-message'); 3 | const sendToSlack = require('./src/slack-send-message'); 4 | const processWithNLP = require('./src/process-with-nlp'); 5 | 6 | var Sequelize = require('sequelize'); 7 | 8 | exports.handler = function (event, context, callback) { 9 | var db = require('./lib/models/db')(Sequelize); 10 | db.Team.findOne({ where: { slack_id: event.team_id } }) 11 | .then( (team) => { 12 | if (team === null) { 13 | // bail. We somehow got a message from a team 14 | // that didn't install the bot. 15 | return ("We somehow got a message from a team that didn't install Quackbot."); 16 | 17 | } else { 18 | return team.latestAuthorization().then( 19 | (authorization) => { 20 | 21 | console.log("Authorization is \n", JSON.stringify(authorization)); 22 | 23 | // add the authorization info to the event 24 | event.authorization = authorization[0].details.bot; 25 | 26 | // Tell the team they're not cool enough. 27 | if (!team.verified) { 28 | console.log('Team not yet validated by DocumentCloud. Informing user ...'); 29 | var message = "Hi! I'm still waiting for the folks at DocumentCloud to say you can use my services. I'll let you know when we're ready to go."; 30 | return sendToSlack(event, message); 31 | } else { 32 | 33 | console.log('Team Verified, handling message'); 34 | 35 | console.log('Event is:', event); 36 | 37 | // Extract command words. 38 | const commandWords = event.text.trim().split(/\s+/); 39 | 40 | // To reach the bot, it must be a DM (in a "D" channel) 41 | // or an @-mention at the start of a line. 42 | 43 | var is_direct_message_to_me = event.channel.match(/^D*/)[0] == "D"; 44 | var command_starts_with_me = (commandWords[0] == `<@${event.authorization.bot_user_id}>`); 45 | 46 | if (!is_direct_message_to_me && !command_starts_with_me) { 47 | console.log('Ignoring message that is none of my beeswax, bye!'); 48 | return; 49 | } 50 | 51 | // handle file uploads - TODO make sure this works 52 | if (is_direct_message_to_me && event.subtype == 'file_share') { 53 | event.command = { 54 | verb: event.file.filetype, 55 | predicate: event.file.url_private 56 | }; 57 | } 58 | 59 | // process the human's request with natural language processing 60 | return processWithNLP(event) 61 | .then(nlpResult => { 62 | 63 | event.nlp = nlpResult; 64 | 65 | // copying to command object for existing bots 66 | event.command = {}; 67 | event.command.verb = event.nlp.action || null; 68 | event.command.predicate = event.nlp.parameters.url || event.nlp.parameters.topic || null; 69 | 70 | sendToSlack(event, event.nlp.fulfillment.speech); 71 | 72 | console.log(`Event posted to ${event.stage} stage with\nverb '${event.command.verb}'\npredicate ${event.command.predicate}.`); 73 | 74 | return routeMessage(event).catch((message) => respondOnError(event, message) ); 75 | }); 76 | } 77 | } 78 | ); 79 | } 80 | }) 81 | .then( 82 | function(){ 83 | db.sequelize.sync().then(function() { 84 | // console.log("handles before:", process._getActiveHandles().length); 85 | return db.sequelize.close().then(function() { 86 | // console.log("handles after:", process._getActiveHandles().length); 87 | }); 88 | }); 89 | } 90 | ) 91 | .then(message => { 92 | console.log(message); 93 | callback(null); 94 | }) 95 | .catch(error => { 96 | console.error(error.message); 97 | callback(null); 98 | }); 99 | }; 100 | 101 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/lib/config/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "development": { 3 | "dialect": "postgres", 4 | "host": "localhost", 5 | "username": "quackbot", 6 | "password": "38TXXcALgH6NdaxqesqiDPzNEG9zucNG", 7 | "database": "quackbot_development" 8 | }, 9 | "test": { 10 | "dialect": "postgres", 11 | "host": "localhost", 12 | "username": "quackbot", 13 | "password": "", 14 | "database": "quackbot_test", 15 | }, 16 | "production": { 17 | "dialect": "postgres", 18 | "host": process.env.DB_HOSTNAME, 19 | "username": process.env.DB_USERNAME, 20 | "password": process.env.DB_PASSWORD, 21 | "database": process.env.DB_NAME 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/lib/config/config.sample.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "development": { 3 | "dialect": "postgres", 4 | "host": "localhost", 5 | "username": "quackbot", 6 | "password": "", 7 | "database": "quackbot_development" 8 | }, 9 | "test": { 10 | "dialect": "postgres", 11 | "host": "localhost", 12 | "username": "quackbot", 13 | "password": "", 14 | "database": "quackbot_test" 15 | }, 16 | "production": { 17 | "dialect": "postgres", 18 | "host": process.env.DB_HOSTNAME, 19 | "username": process.env.DB_USERNAME, 20 | "password": process.env.DB_PASSWORD, 21 | "database": process.env.DB_NAME, 22 | "logging": false 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/lib/models/authorization.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | var Authorization = sequelize.define('authorization', { 4 | id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, 5 | team_id: { type: DataTypes.INTEGER }, 6 | details: { type: DataTypes.JSONB }, 7 | }, { 8 | define: { timestamps: true }, 9 | underscored: true 10 | }); 11 | return Authorization; 12 | }; 13 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/lib/models/db.js: -------------------------------------------------------------------------------- 1 | "use_strict"; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const basename = path.basename(module.filename); 6 | const env = process.env.NODE_ENV || 'development'; 7 | const config = require(__dirname + '/../config/config.js')[env]; 8 | 9 | var TeamStore = function(Sequelize, options) { 10 | 11 | this.sequelize = new Sequelize(options || config); 12 | var Team = require(__dirname+'/team')(this.sequelize, Sequelize); 13 | var Authorization = require(__dirname+'/authorization')(this.sequelize, Sequelize); 14 | 15 | Team.hasMany(Authorization); 16 | Authorization.belongsTo(Team); 17 | 18 | this.Team = Team; 19 | this.Authorization = Authorization; 20 | 21 | return this; 22 | }; 23 | 24 | TeamStore.prototype.close = function(){ 25 | return this.sequelize.sync().then(function() { 26 | console.log("handles before:", process._getActiveHandles().length); 27 | return this.sequelize.close().then(function() { 28 | console.log("handles after:", process._getActiveHandles().length); 29 | }); 30 | }); 31 | }; 32 | 33 | module.exports = TeamStore; -------------------------------------------------------------------------------- /slack-events-api-message-handler/lib/models/team.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | var Team = sequelize.define('team', { 4 | id: { 5 | type: DataTypes.INTEGER, 6 | primaryKey: true, 7 | autoIncrement: true 8 | }, 9 | slack_id: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | verified: { 14 | type: DataTypes.BOOLEAN, 15 | default: false 16 | }, 17 | verified_by: { type: DataTypes.INTEGER }, 18 | verified_at: { type: DataTypes.TIME }, 19 | }, { 20 | define: { timestamps: true }, 21 | underscored: true 22 | }); 23 | 24 | Team.prototype.latestAuthorization = function() { 25 | return this.getAuthorizations({ 26 | limit:1, 27 | order:[["created_at","desc"]] 28 | }); 29 | }; 30 | 31 | return Team; 32 | }; 33 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/lib/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | var User = sequelize.define('User', { 4 | name: DataTypes.STRING 5 | }, { 6 | classMethods: { 7 | associate: function(models) { 8 | // associations can be defined here 9 | } 10 | } 11 | }); 12 | return User; 13 | }; -------------------------------------------------------------------------------- /slack-events-api-message-handler/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-events-api-message-handler", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/geojson": { 8 | "version": "1.0.4", 9 | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-1.0.4.tgz", 10 | "integrity": "sha512-idP+xKlqFG1egc5M52mDat/Z0VMrwY93LCd81dzW/IjeTIYTMWuzVu+fBf19QK/mX9K7jM2UNN5nzDRgM950GA==" 11 | }, 12 | "@types/node": { 13 | "version": "8.0.31", 14 | "resolved": "https://registry.npmjs.org/@types/node/-/node-8.0.31.tgz", 15 | "integrity": "sha512-R+LdMJHJQwRd/Ca0Nr5KnwbSWHxTD3DWz4ivqoPeNH+YPcuirMWK+Ti9Mx32jOecmPhHOCd+6CefU5e1eVq2Ew==" 16 | }, 17 | "agent-base": { 18 | "version": "2.1.1", 19 | "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-2.1.1.tgz", 20 | "integrity": "sha1-1t4Q1a9hMtW9aSQn1G/FOFOQlMc=", 21 | "requires": { 22 | "extend": "3.0.1", 23 | "semver": "5.0.3" 24 | }, 25 | "dependencies": { 26 | "semver": { 27 | "version": "5.0.3", 28 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.0.3.tgz", 29 | "integrity": "sha1-d0Zt5YnNXTyV8TiqeLxWmjy10no=" 30 | } 31 | } 32 | }, 33 | "archiver": { 34 | "version": "2.0.3", 35 | "resolved": "https://registry.npmjs.org/archiver/-/archiver-2.0.3.tgz", 36 | "integrity": "sha1-tDYLtYSvFDeZGUJxbyHXxSPR270=", 37 | "requires": { 38 | "archiver-utils": "1.3.0", 39 | "async": "2.5.0", 40 | "buffer-crc32": "0.2.13", 41 | "glob": "7.1.2", 42 | "lodash": "4.17.4", 43 | "readable-stream": "2.3.3", 44 | "tar-stream": "1.5.4", 45 | "walkdir": "0.0.11", 46 | "zip-stream": "1.2.0" 47 | } 48 | }, 49 | "archiver-utils": { 50 | "version": "1.3.0", 51 | "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-1.3.0.tgz", 52 | "integrity": "sha1-5QtMCccL89aA4y/xt5lOn52JUXQ=", 53 | "requires": { 54 | "glob": "7.1.2", 55 | "graceful-fs": "4.1.11", 56 | "lazystream": "1.0.0", 57 | "lodash": "4.17.4", 58 | "normalize-path": "2.1.1", 59 | "readable-stream": "2.3.3" 60 | } 61 | }, 62 | "ast-types": { 63 | "version": "0.9.13", 64 | "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.9.13.tgz", 65 | "integrity": "sha512-72w1vrspLfSP4htDZWMgDya3gz7VFIojiaxWdXfJkpR/KouBvJZ2xoHxG79VwdGr8ZdG/b6zgwqoIG24QtRqCQ==" 66 | }, 67 | "async": { 68 | "version": "2.5.0", 69 | "resolved": "https://registry.npmjs.org/async/-/async-2.5.0.tgz", 70 | "integrity": "sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFTKE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==", 71 | "requires": { 72 | "lodash": "4.17.4" 73 | } 74 | }, 75 | "aws-sdk": { 76 | "version": "2.125.0", 77 | "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.125.0.tgz", 78 | "integrity": "sha1-wMMTG1fu9KBRxyZsw7R6cE5+Nqs=", 79 | "requires": { 80 | "buffer": "4.9.1", 81 | "crypto-browserify": "1.0.9", 82 | "events": "1.1.1", 83 | "jmespath": "0.15.0", 84 | "querystring": "0.2.0", 85 | "sax": "1.2.1", 86 | "url": "0.10.3", 87 | "uuid": "3.0.1", 88 | "xml2js": "0.4.17", 89 | "xmlbuilder": "4.2.1" 90 | }, 91 | "dependencies": { 92 | "uuid": { 93 | "version": "3.0.1", 94 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", 95 | "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=" 96 | } 97 | } 98 | }, 99 | "balanced-match": { 100 | "version": "1.0.0", 101 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 102 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" 103 | }, 104 | "base64-js": { 105 | "version": "1.2.1", 106 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.1.tgz", 107 | "integrity": "sha512-dwVUVIXsBZXwTuwnXI9RK8sBmgq09NDHzyR9SAph9eqk76gKK2JSQmZARC2zRC81JC2QTtxD0ARU5qTS25gIGw==" 108 | }, 109 | "bl": { 110 | "version": "1.2.1", 111 | "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.1.tgz", 112 | "integrity": "sha1-ysMo977kVzDUBLaSID/LWQ4XLV4=", 113 | "requires": { 114 | "readable-stream": "2.3.3" 115 | } 116 | }, 117 | "bluebird": { 118 | "version": "3.5.0", 119 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz", 120 | "integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw=" 121 | }, 122 | "brace-expansion": { 123 | "version": "1.1.8", 124 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", 125 | "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", 126 | "requires": { 127 | "balanced-match": "1.0.0", 128 | "concat-map": "0.0.1" 129 | } 130 | }, 131 | "browserify-zlib": { 132 | "version": "0.1.4", 133 | "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", 134 | "integrity": "sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=", 135 | "requires": { 136 | "pako": "0.2.9" 137 | } 138 | }, 139 | "buffer": { 140 | "version": "4.9.1", 141 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", 142 | "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", 143 | "requires": { 144 | "base64-js": "1.2.1", 145 | "ieee754": "1.1.8", 146 | "isarray": "1.0.0" 147 | } 148 | }, 149 | "buffer-crc32": { 150 | "version": "0.2.13", 151 | "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", 152 | "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" 153 | }, 154 | "buffer-writer": { 155 | "version": "1.0.1", 156 | "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz", 157 | "integrity": "sha1-Iqk2kB4wKa/NdUfrRIfOtpejvwg=" 158 | }, 159 | "bytes": { 160 | "version": "3.0.0", 161 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", 162 | "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" 163 | }, 164 | "chownr": { 165 | "version": "1.0.1", 166 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.0.1.tgz", 167 | "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=" 168 | }, 169 | "claudia": { 170 | "version": "2.14.2", 171 | "resolved": "https://registry.npmjs.org/claudia/-/claudia-2.14.2.tgz", 172 | "integrity": "sha1-OWtzs0J3ukSFEvUf0F1FXxY1YmM=", 173 | "requires": { 174 | "archiver": "2.0.3", 175 | "aws-sdk": "2.125.0", 176 | "gunzip-maybe": "1.4.1", 177 | "minimal-request-promise": "1.4.0", 178 | "minimist": "1.2.0", 179 | "oh-no-i-insist": "1.1.1", 180 | "proxy-agent": "2.1.0", 181 | "sequential-promise-map": "1.0.4", 182 | "shelljs": "0.5.3", 183 | "tar-fs": "1.15.3", 184 | "uuid": "2.0.3" 185 | }, 186 | "dependencies": { 187 | "uuid": { 188 | "version": "2.0.3", 189 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", 190 | "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" 191 | } 192 | } 193 | }, 194 | "cls-bluebird": { 195 | "version": "2.0.1", 196 | "resolved": "https://registry.npmjs.org/cls-bluebird/-/cls-bluebird-2.0.1.tgz", 197 | "integrity": "sha1-wlmkgK4CwOUGE0MHuxPbMERu4uc=", 198 | "requires": { 199 | "is-bluebird": "1.0.2", 200 | "shimmer": "1.1.0" 201 | } 202 | }, 203 | "co": { 204 | "version": "4.6.0", 205 | "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", 206 | "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" 207 | }, 208 | "compress-commons": { 209 | "version": "1.2.0", 210 | "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-1.2.0.tgz", 211 | "integrity": "sha1-WFhwku8g03y1i68AARLJJ4/3O58=", 212 | "requires": { 213 | "buffer-crc32": "0.2.13", 214 | "crc32-stream": "2.0.0", 215 | "normalize-path": "2.1.1", 216 | "readable-stream": "2.3.3" 217 | } 218 | }, 219 | "concat-map": { 220 | "version": "0.0.1", 221 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 222 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 223 | }, 224 | "core-util-is": { 225 | "version": "1.0.2", 226 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", 227 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" 228 | }, 229 | "crc": { 230 | "version": "3.5.0", 231 | "resolved": "https://registry.npmjs.org/crc/-/crc-3.5.0.tgz", 232 | "integrity": "sha1-mLi6fUiWZbo5efWbITgTdBAaGWQ=" 233 | }, 234 | "crc32-stream": { 235 | "version": "2.0.0", 236 | "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-2.0.0.tgz", 237 | "integrity": "sha1-483TtN8xaN10494/u8t7KX/pCPQ=", 238 | "requires": { 239 | "crc": "3.5.0", 240 | "readable-stream": "2.3.3" 241 | } 242 | }, 243 | "crypto-browserify": { 244 | "version": "1.0.9", 245 | "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-1.0.9.tgz", 246 | "integrity": "sha1-zFRJaF37hesRyYKKzHy4erW7/MA=" 247 | }, 248 | "data-uri-to-buffer": { 249 | "version": "1.2.0", 250 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz", 251 | "integrity": "sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ==" 252 | }, 253 | "debug": { 254 | "version": "3.1.0", 255 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 256 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 257 | "requires": { 258 | "ms": "2.0.0" 259 | } 260 | }, 261 | "deep-is": { 262 | "version": "0.1.3", 263 | "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", 264 | "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" 265 | }, 266 | "degenerator": { 267 | "version": "1.0.4", 268 | "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz", 269 | "integrity": "sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=", 270 | "requires": { 271 | "ast-types": "0.9.13", 272 | "escodegen": "1.9.0", 273 | "esprima": "3.1.3" 274 | } 275 | }, 276 | "depd": { 277 | "version": "1.1.1", 278 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", 279 | "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" 280 | }, 281 | "dottie": { 282 | "version": "2.0.0", 283 | "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.0.tgz", 284 | "integrity": "sha1-2hkZgci41xPKARXViYzzl8Lw3dA=" 285 | }, 286 | "duplexify": { 287 | "version": "3.5.1", 288 | "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.5.1.tgz", 289 | "integrity": "sha512-j5goxHTwVED1Fpe5hh3q9R93Kip0Bg2KVAt4f8CEYM3UEwYcPSvWbXaUQOzdX/HtiNomipv+gU7ASQPDbV7pGQ==", 290 | "requires": { 291 | "end-of-stream": "1.4.0", 292 | "inherits": "2.0.3", 293 | "readable-stream": "2.3.3", 294 | "stream-shift": "1.0.0" 295 | } 296 | }, 297 | "end-of-stream": { 298 | "version": "1.4.0", 299 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.0.tgz", 300 | "integrity": "sha1-epDYM+/abPpurA9JSduw+tOmMgY=", 301 | "requires": { 302 | "once": "1.4.0" 303 | } 304 | }, 305 | "es6-promise": { 306 | "version": "4.1.1", 307 | "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.1.1.tgz", 308 | "integrity": "sha512-OaU1hHjgJf+b0NzsxCg7NdIYERD6Hy/PEmFLTjw+b65scuisG3Kt4QoTvJ66BBkPZ581gr0kpoVzKnxniM8nng==" 309 | }, 310 | "es6-promisify": { 311 | "version": "5.0.0", 312 | "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", 313 | "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", 314 | "requires": { 315 | "es6-promise": "4.1.1" 316 | } 317 | }, 318 | "escodegen": { 319 | "version": "1.9.0", 320 | "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.9.0.tgz", 321 | "integrity": "sha512-v0MYvNQ32bzwoG2OSFzWAkuahDQHK92JBN0pTAALJ4RIxEZe766QJPDR8Hqy7XNUy5K3fnVL76OqYAdc4TZEIw==", 322 | "requires": { 323 | "esprima": "3.1.3", 324 | "estraverse": "4.2.0", 325 | "esutils": "2.0.2", 326 | "optionator": "0.8.2", 327 | "source-map": "0.5.7" 328 | } 329 | }, 330 | "esprima": { 331 | "version": "3.1.3", 332 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", 333 | "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" 334 | }, 335 | "estraverse": { 336 | "version": "4.2.0", 337 | "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", 338 | "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=" 339 | }, 340 | "esutils": { 341 | "version": "2.0.2", 342 | "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", 343 | "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" 344 | }, 345 | "events": { 346 | "version": "1.1.1", 347 | "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", 348 | "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" 349 | }, 350 | "extend": { 351 | "version": "3.0.1", 352 | "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", 353 | "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" 354 | }, 355 | "fast-levenshtein": { 356 | "version": "2.0.6", 357 | "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", 358 | "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" 359 | }, 360 | "file-uri-to-path": { 361 | "version": "1.0.0", 362 | "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 363 | "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" 364 | }, 365 | "fs.realpath": { 366 | "version": "1.0.0", 367 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 368 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" 369 | }, 370 | "ftp": { 371 | "version": "0.3.10", 372 | "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", 373 | "integrity": "sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=", 374 | "requires": { 375 | "readable-stream": "1.1.14", 376 | "xregexp": "2.0.0" 377 | }, 378 | "dependencies": { 379 | "isarray": { 380 | "version": "0.0.1", 381 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", 382 | "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" 383 | }, 384 | "readable-stream": { 385 | "version": "1.1.14", 386 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", 387 | "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", 388 | "requires": { 389 | "core-util-is": "1.0.2", 390 | "inherits": "2.0.3", 391 | "isarray": "0.0.1", 392 | "string_decoder": "0.10.31" 393 | } 394 | }, 395 | "string_decoder": { 396 | "version": "0.10.31", 397 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", 398 | "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" 399 | } 400 | } 401 | }, 402 | "generic-pool": { 403 | "version": "3.1.8", 404 | "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.1.8.tgz", 405 | "integrity": "sha1-CYRLZUW8kXfsIYvTXUrYlMZb4nE=" 406 | }, 407 | "get-uri": { 408 | "version": "2.0.1", 409 | "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.1.tgz", 410 | "integrity": "sha512-7aelVrYqCLuVjq2kEKRTH8fXPTC0xKTkM+G7UlFkEwCXY3sFbSxvY375JoFowOAYbkaU47SrBvOefUlLZZ+6QA==", 411 | "requires": { 412 | "data-uri-to-buffer": "1.2.0", 413 | "debug": "2.6.9", 414 | "extend": "3.0.1", 415 | "file-uri-to-path": "1.0.0", 416 | "ftp": "0.3.10", 417 | "readable-stream": "2.3.3" 418 | }, 419 | "dependencies": { 420 | "debug": { 421 | "version": "2.6.9", 422 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 423 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 424 | "requires": { 425 | "ms": "2.0.0" 426 | } 427 | } 428 | } 429 | }, 430 | "glob": { 431 | "version": "7.1.2", 432 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 433 | "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", 434 | "requires": { 435 | "fs.realpath": "1.0.0", 436 | "inflight": "1.0.6", 437 | "inherits": "2.0.3", 438 | "minimatch": "3.0.4", 439 | "once": "1.4.0", 440 | "path-is-absolute": "1.0.1" 441 | } 442 | }, 443 | "graceful-fs": { 444 | "version": "4.1.11", 445 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", 446 | "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" 447 | }, 448 | "gunzip-maybe": { 449 | "version": "1.4.1", 450 | "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.1.tgz", 451 | "integrity": "sha512-qtutIKMthNJJgeHQS7kZ9FqDq59/Wn0G2HYCRNjpup7yKfVI6/eqwpmroyZGFoCYaG+sW6psNVb4zoLADHpp2g==", 452 | "requires": { 453 | "browserify-zlib": "0.1.4", 454 | "is-deflate": "1.0.0", 455 | "is-gzip": "1.0.0", 456 | "peek-stream": "1.1.2", 457 | "pumpify": "1.3.5", 458 | "through2": "2.0.3" 459 | } 460 | }, 461 | "http-errors": { 462 | "version": "1.6.2", 463 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", 464 | "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", 465 | "requires": { 466 | "depd": "1.1.1", 467 | "inherits": "2.0.3", 468 | "setprototypeof": "1.0.3", 469 | "statuses": "1.3.1" 470 | } 471 | }, 472 | "http-proxy-agent": { 473 | "version": "1.0.0", 474 | "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-1.0.0.tgz", 475 | "integrity": "sha1-zBzjjkU7+YSg93AtLdWcc9CBKEo=", 476 | "requires": { 477 | "agent-base": "2.1.1", 478 | "debug": "2.6.9", 479 | "extend": "3.0.1" 480 | }, 481 | "dependencies": { 482 | "debug": { 483 | "version": "2.6.9", 484 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 485 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 486 | "requires": { 487 | "ms": "2.0.0" 488 | } 489 | } 490 | } 491 | }, 492 | "https-proxy-agent": { 493 | "version": "1.0.0", 494 | "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz", 495 | "integrity": "sha1-NffabEjOTdv6JkiRrFk+5f+GceY=", 496 | "requires": { 497 | "agent-base": "2.1.1", 498 | "debug": "2.6.9", 499 | "extend": "3.0.1" 500 | }, 501 | "dependencies": { 502 | "debug": { 503 | "version": "2.6.9", 504 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 505 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 506 | "requires": { 507 | "ms": "2.0.0" 508 | } 509 | } 510 | } 511 | }, 512 | "iconv-lite": { 513 | "version": "0.4.19", 514 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", 515 | "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" 516 | }, 517 | "ieee754": { 518 | "version": "1.1.8", 519 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", 520 | "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" 521 | }, 522 | "inflection": { 523 | "version": "1.12.0", 524 | "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz", 525 | "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=" 526 | }, 527 | "inflight": { 528 | "version": "1.0.6", 529 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 530 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 531 | "requires": { 532 | "once": "1.4.0", 533 | "wrappy": "1.0.2" 534 | } 535 | }, 536 | "inherits": { 537 | "version": "2.0.3", 538 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 539 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 540 | }, 541 | "ip": { 542 | "version": "1.1.5", 543 | "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", 544 | "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" 545 | }, 546 | "is-bluebird": { 547 | "version": "1.0.2", 548 | "resolved": "https://registry.npmjs.org/is-bluebird/-/is-bluebird-1.0.2.tgz", 549 | "integrity": "sha1-CWQ5Bg9KpBGr7hkUOoTWpVNG1uI=" 550 | }, 551 | "is-deflate": { 552 | "version": "1.0.0", 553 | "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", 554 | "integrity": "sha1-yGKQHDwWH7CdrHzcfnhPgOmPLxQ=" 555 | }, 556 | "is-gzip": { 557 | "version": "1.0.0", 558 | "resolved": "https://registry.npmjs.org/is-gzip/-/is-gzip-1.0.0.tgz", 559 | "integrity": "sha1-bKiwe5nHeZgCWQDlVc7Y7YCHmoM=" 560 | }, 561 | "isarray": { 562 | "version": "1.0.0", 563 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 564 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" 565 | }, 566 | "jmespath": { 567 | "version": "0.15.0", 568 | "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", 569 | "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" 570 | }, 571 | "js-string-escape": { 572 | "version": "1.0.1", 573 | "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", 574 | "integrity": "sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=" 575 | }, 576 | "lazystream": { 577 | "version": "1.0.0", 578 | "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", 579 | "integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=", 580 | "requires": { 581 | "readable-stream": "2.3.3" 582 | } 583 | }, 584 | "levn": { 585 | "version": "0.3.0", 586 | "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", 587 | "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", 588 | "requires": { 589 | "prelude-ls": "1.1.2", 590 | "type-check": "0.3.2" 591 | } 592 | }, 593 | "lodash": { 594 | "version": "4.17.4", 595 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", 596 | "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" 597 | }, 598 | "lru-cache": { 599 | "version": "2.6.5", 600 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.6.5.tgz", 601 | "integrity": "sha1-5W1jVBSO3o13B7WNFDIg/QjfD9U=" 602 | }, 603 | "minimal-request-promise": { 604 | "version": "1.4.0", 605 | "resolved": "https://registry.npmjs.org/minimal-request-promise/-/minimal-request-promise-1.4.0.tgz", 606 | "integrity": "sha1-BLAJGAU3RAs/gmiaS/v4YkJo4H8=" 607 | }, 608 | "minimatch": { 609 | "version": "3.0.4", 610 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 611 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 612 | "requires": { 613 | "brace-expansion": "1.1.8" 614 | } 615 | }, 616 | "minimist": { 617 | "version": "1.2.0", 618 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 619 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 620 | }, 621 | "mkdirp": { 622 | "version": "0.5.1", 623 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 624 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 625 | "requires": { 626 | "minimist": "0.0.8" 627 | }, 628 | "dependencies": { 629 | "minimist": { 630 | "version": "0.0.8", 631 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 632 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 633 | } 634 | } 635 | }, 636 | "moment": { 637 | "version": "2.18.1", 638 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz", 639 | "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8=" 640 | }, 641 | "moment-timezone": { 642 | "version": "0.5.13", 643 | "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.13.tgz", 644 | "integrity": "sha1-mc5cfYJyYusPH3AgRBd/YHRde5A=", 645 | "requires": { 646 | "moment": "2.18.1" 647 | } 648 | }, 649 | "ms": { 650 | "version": "2.0.0", 651 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 652 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 653 | }, 654 | "netmask": { 655 | "version": "1.0.6", 656 | "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", 657 | "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=" 658 | }, 659 | "normalize-path": { 660 | "version": "2.1.1", 661 | "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", 662 | "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", 663 | "requires": { 664 | "remove-trailing-separator": "1.1.0" 665 | } 666 | }, 667 | "object-assign": { 668 | "version": "4.1.0", 669 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", 670 | "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A=" 671 | }, 672 | "oh-no-i-insist": { 673 | "version": "1.1.1", 674 | "resolved": "https://registry.npmjs.org/oh-no-i-insist/-/oh-no-i-insist-1.1.1.tgz", 675 | "integrity": "sha1-r28S4tQzZoObrkX4yHC5dqEe7jU=" 676 | }, 677 | "once": { 678 | "version": "1.4.0", 679 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 680 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 681 | "requires": { 682 | "wrappy": "1.0.2" 683 | } 684 | }, 685 | "optionator": { 686 | "version": "0.8.2", 687 | "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", 688 | "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", 689 | "requires": { 690 | "deep-is": "0.1.3", 691 | "fast-levenshtein": "2.0.6", 692 | "levn": "0.3.0", 693 | "prelude-ls": "1.1.2", 694 | "type-check": "0.3.2", 695 | "wordwrap": "1.0.0" 696 | } 697 | }, 698 | "pac-proxy-agent": { 699 | "version": "2.0.0", 700 | "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-2.0.0.tgz", 701 | "integrity": "sha512-t57UiJpi5mFLTvjheC1SNSwIhml3+ElNOj69iRrydtQXZJr8VIFYSDtyPi/3ZysA62kD2dmww6pDlzk0VaONZg==", 702 | "requires": { 703 | "agent-base": "2.1.1", 704 | "debug": "2.6.9", 705 | "get-uri": "2.0.1", 706 | "http-proxy-agent": "1.0.0", 707 | "https-proxy-agent": "1.0.0", 708 | "pac-resolver": "3.0.0", 709 | "raw-body": "2.3.2", 710 | "socks-proxy-agent": "3.0.1" 711 | }, 712 | "dependencies": { 713 | "debug": { 714 | "version": "2.6.9", 715 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 716 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 717 | "requires": { 718 | "ms": "2.0.0" 719 | } 720 | }, 721 | "socks-proxy-agent": { 722 | "version": "3.0.1", 723 | "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz", 724 | "integrity": "sha512-ZwEDymm204mTzvdqyUqOdovVr2YRd2NYskrYrF2LXyZ9qDiMAoFESGK8CRphiO7rtbo2Y757k2Nia3x2hGtalA==", 725 | "requires": { 726 | "agent-base": "4.1.1", 727 | "socks": "1.1.10" 728 | }, 729 | "dependencies": { 730 | "agent-base": { 731 | "version": "4.1.1", 732 | "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.1.1.tgz", 733 | "integrity": "sha512-yWGUUmCZD/33IRjG2It94PzixT8lX+47Uq8fjmd0cgQWITCMrJuXFaVIMnGDmDnZGGKAGdwTx8UGeU8lMR2urA==", 734 | "requires": { 735 | "es6-promisify": "5.0.0" 736 | } 737 | } 738 | } 739 | } 740 | } 741 | }, 742 | "pac-resolver": { 743 | "version": "3.0.0", 744 | "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-3.0.0.tgz", 745 | "integrity": "sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==", 746 | "requires": { 747 | "co": "4.6.0", 748 | "degenerator": "1.0.4", 749 | "ip": "1.1.5", 750 | "netmask": "1.0.6", 751 | "thunkify": "2.1.2" 752 | } 753 | }, 754 | "packet-reader": { 755 | "version": "0.3.1", 756 | "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz", 757 | "integrity": "sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc=" 758 | }, 759 | "pako": { 760 | "version": "0.2.9", 761 | "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", 762 | "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=" 763 | }, 764 | "path-is-absolute": { 765 | "version": "1.0.1", 766 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 767 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" 768 | }, 769 | "peek-stream": { 770 | "version": "1.1.2", 771 | "resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.2.tgz", 772 | "integrity": "sha1-l+t2NlvP2MieKH9VyLadTD6bzFI=", 773 | "requires": { 774 | "duplexify": "3.5.1", 775 | "through2": "2.0.3" 776 | } 777 | }, 778 | "pg": { 779 | "version": "6.4.2", 780 | "resolved": "https://registry.npmjs.org/pg/-/pg-6.4.2.tgz", 781 | "integrity": "sha1-w2QBEGDqx6UHoq4GPrhX7OkQ4n8=", 782 | "requires": { 783 | "buffer-writer": "1.0.1", 784 | "js-string-escape": "1.0.1", 785 | "packet-reader": "0.3.1", 786 | "pg-connection-string": "0.1.3", 787 | "pg-pool": "1.8.0", 788 | "pg-types": "1.12.1", 789 | "pgpass": "1.0.2", 790 | "semver": "4.3.2" 791 | }, 792 | "dependencies": { 793 | "semver": { 794 | "version": "4.3.2", 795 | "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", 796 | "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" 797 | } 798 | } 799 | }, 800 | "pg-connection-string": { 801 | "version": "0.1.3", 802 | "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", 803 | "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" 804 | }, 805 | "pg-pool": { 806 | "version": "1.8.0", 807 | "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-1.8.0.tgz", 808 | "integrity": "sha1-9+xzgkw3oD8Hb1G/33DjQBR8Tzc=", 809 | "requires": { 810 | "generic-pool": "2.4.3", 811 | "object-assign": "4.1.0" 812 | }, 813 | "dependencies": { 814 | "generic-pool": { 815 | "version": "2.4.3", 816 | "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-2.4.3.tgz", 817 | "integrity": "sha1-eAw29p360FpaBF3Te+etyhGk9v8=" 818 | } 819 | } 820 | }, 821 | "pg-types": { 822 | "version": "1.12.1", 823 | "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.12.1.tgz", 824 | "integrity": "sha1-1kCH45A7WP+q0nnnWVxSIIoUw9I=", 825 | "requires": { 826 | "postgres-array": "1.0.2", 827 | "postgres-bytea": "1.0.0", 828 | "postgres-date": "1.0.3", 829 | "postgres-interval": "1.1.1" 830 | } 831 | }, 832 | "pgpass": { 833 | "version": "1.0.2", 834 | "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", 835 | "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", 836 | "requires": { 837 | "split": "1.0.1" 838 | } 839 | }, 840 | "postgres-array": { 841 | "version": "1.0.2", 842 | "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.2.tgz", 843 | "integrity": "sha1-jgsy6wO/d6XAp4UeBEHBaaJWojg=" 844 | }, 845 | "postgres-bytea": { 846 | "version": "1.0.0", 847 | "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", 848 | "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" 849 | }, 850 | "postgres-date": { 851 | "version": "1.0.3", 852 | "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.3.tgz", 853 | "integrity": "sha1-4tiXAu/bJY/52c7g/pG9BpdSV6g=" 854 | }, 855 | "postgres-interval": { 856 | "version": "1.1.1", 857 | "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.1.1.tgz", 858 | "integrity": "sha512-OkuCi9t/3CZmeQreutGgx/OVNv9MKHGIT5jH8KldQ4NLYXkvmT9nDVxEuCENlNwhlGPE374oA/xMqn05G49pHA==", 859 | "requires": { 860 | "xtend": "4.0.1" 861 | } 862 | }, 863 | "prelude-ls": { 864 | "version": "1.1.2", 865 | "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", 866 | "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" 867 | }, 868 | "process-nextick-args": { 869 | "version": "1.0.7", 870 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", 871 | "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" 872 | }, 873 | "proxy-agent": { 874 | "version": "2.1.0", 875 | "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-2.1.0.tgz", 876 | "integrity": "sha512-I23qaUnXmU/ItpXWQcMj9wMcZQTXnJNI7nakSR+q95Iht8H0+w3dCgTJdfnOQqOCX1FZwKLSgurCyEt11LM6OA==", 877 | "requires": { 878 | "agent-base": "2.1.1", 879 | "debug": "2.6.9", 880 | "extend": "3.0.1", 881 | "http-proxy-agent": "1.0.0", 882 | "https-proxy-agent": "1.0.0", 883 | "lru-cache": "2.6.5", 884 | "pac-proxy-agent": "2.0.0", 885 | "socks-proxy-agent": "2.1.1" 886 | }, 887 | "dependencies": { 888 | "debug": { 889 | "version": "2.6.9", 890 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 891 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 892 | "requires": { 893 | "ms": "2.0.0" 894 | } 895 | } 896 | } 897 | }, 898 | "pump": { 899 | "version": "1.0.2", 900 | "resolved": "https://registry.npmjs.org/pump/-/pump-1.0.2.tgz", 901 | "integrity": "sha1-Oz7mUS+U8OV1U4wXmV+fFpkKXVE=", 902 | "requires": { 903 | "end-of-stream": "1.4.0", 904 | "once": "1.4.0" 905 | } 906 | }, 907 | "pumpify": { 908 | "version": "1.3.5", 909 | "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.3.5.tgz", 910 | "integrity": "sha1-G2ccYZlAq8rqwK0OOjwWS+dgmTs=", 911 | "requires": { 912 | "duplexify": "3.5.1", 913 | "inherits": "2.0.3", 914 | "pump": "1.0.2" 915 | } 916 | }, 917 | "punycode": { 918 | "version": "1.3.2", 919 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", 920 | "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" 921 | }, 922 | "querystring": { 923 | "version": "0.2.0", 924 | "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", 925 | "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" 926 | }, 927 | "raw-body": { 928 | "version": "2.3.2", 929 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", 930 | "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", 931 | "requires": { 932 | "bytes": "3.0.0", 933 | "http-errors": "1.6.2", 934 | "iconv-lite": "0.4.19", 935 | "unpipe": "1.0.0" 936 | } 937 | }, 938 | "readable-stream": { 939 | "version": "2.3.3", 940 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", 941 | "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", 942 | "requires": { 943 | "core-util-is": "1.0.2", 944 | "inherits": "2.0.3", 945 | "isarray": "1.0.0", 946 | "process-nextick-args": "1.0.7", 947 | "safe-buffer": "5.1.1", 948 | "string_decoder": "1.0.3", 949 | "util-deprecate": "1.0.2" 950 | } 951 | }, 952 | "remove-trailing-separator": { 953 | "version": "1.1.0", 954 | "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", 955 | "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" 956 | }, 957 | "retry-as-promised": { 958 | "version": "2.3.1", 959 | "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-2.3.1.tgz", 960 | "integrity": "sha1-91BZGD+XMHccCbrR7tV1N5McvJ0=", 961 | "requires": { 962 | "bluebird": "3.5.0", 963 | "debug": "2.6.9" 964 | }, 965 | "dependencies": { 966 | "debug": { 967 | "version": "2.6.9", 968 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 969 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 970 | "requires": { 971 | "ms": "2.0.0" 972 | } 973 | } 974 | } 975 | }, 976 | "safe-buffer": { 977 | "version": "5.1.1", 978 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", 979 | "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" 980 | }, 981 | "sax": { 982 | "version": "1.2.1", 983 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", 984 | "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" 985 | }, 986 | "semver": { 987 | "version": "5.4.1", 988 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", 989 | "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" 990 | }, 991 | "sequelize": { 992 | "version": "4.13.2", 993 | "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-4.13.2.tgz", 994 | "integrity": "sha512-YvPw5cot4ETuf4ddaF2yDykN0dHHQUQvRGi2myjZZLyXpiT4IsrEAabAMIzfs4NxbG+AQh9sFf3PrAMXuQjthw==", 995 | "requires": { 996 | "bluebird": "3.5.0", 997 | "cls-bluebird": "2.0.1", 998 | "debug": "3.1.0", 999 | "depd": "1.1.1", 1000 | "dottie": "2.0.0", 1001 | "generic-pool": "3.1.8", 1002 | "inflection": "1.12.0", 1003 | "lodash": "4.17.4", 1004 | "moment": "2.18.1", 1005 | "moment-timezone": "0.5.13", 1006 | "retry-as-promised": "2.3.1", 1007 | "semver": "5.4.1", 1008 | "terraformer-wkt-parser": "1.1.2", 1009 | "toposort-class": "1.0.1", 1010 | "uuid": "3.1.0", 1011 | "validator": "8.2.0", 1012 | "wkx": "0.4.2" 1013 | } 1014 | }, 1015 | "sequential-promise-map": { 1016 | "version": "1.0.4", 1017 | "resolved": "https://registry.npmjs.org/sequential-promise-map/-/sequential-promise-map-1.0.4.tgz", 1018 | "integrity": "sha1-6+SlyPmF5yDcjyBVVFp2x4xFGwU=" 1019 | }, 1020 | "setprototypeof": { 1021 | "version": "1.0.3", 1022 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", 1023 | "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" 1024 | }, 1025 | "shelljs": { 1026 | "version": "0.5.3", 1027 | "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.5.3.tgz", 1028 | "integrity": "sha1-xUmCuZbHbvDB5rWfvcWCX1txMRM=" 1029 | }, 1030 | "shimmer": { 1031 | "version": "1.1.0", 1032 | "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.1.0.tgz", 1033 | "integrity": "sha1-l9c3cTf/u6tCVSLkKf4KqJpIizU=" 1034 | }, 1035 | "smart-buffer": { 1036 | "version": "1.1.15", 1037 | "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-1.1.15.tgz", 1038 | "integrity": "sha1-fxFLW2X6s+KjWqd1uxLw0cZJvxY=" 1039 | }, 1040 | "socks": { 1041 | "version": "1.1.10", 1042 | "resolved": "https://registry.npmjs.org/socks/-/socks-1.1.10.tgz", 1043 | "integrity": "sha1-W4t/x8jzQcU+0FbpKbe/Tei6e1o=", 1044 | "requires": { 1045 | "ip": "1.1.5", 1046 | "smart-buffer": "1.1.15" 1047 | } 1048 | }, 1049 | "socks-proxy-agent": { 1050 | "version": "2.1.1", 1051 | "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-2.1.1.tgz", 1052 | "integrity": "sha512-sFtmYqdUK5dAMh85H0LEVFUCO7OhJJe1/z2x/Z6mxp3s7/QPf1RkZmpZy+BpuU0bEjcV9npqKjq9Y3kwFUjnxw==", 1053 | "requires": { 1054 | "agent-base": "2.1.1", 1055 | "extend": "3.0.1", 1056 | "socks": "1.1.10" 1057 | } 1058 | }, 1059 | "source-map": { 1060 | "version": "0.5.7", 1061 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", 1062 | "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", 1063 | "optional": true 1064 | }, 1065 | "split": { 1066 | "version": "1.0.1", 1067 | "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", 1068 | "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", 1069 | "requires": { 1070 | "through": "2.3.8" 1071 | } 1072 | }, 1073 | "statuses": { 1074 | "version": "1.3.1", 1075 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", 1076 | "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=" 1077 | }, 1078 | "stream-shift": { 1079 | "version": "1.0.0", 1080 | "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", 1081 | "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=" 1082 | }, 1083 | "string_decoder": { 1084 | "version": "1.0.3", 1085 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", 1086 | "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", 1087 | "requires": { 1088 | "safe-buffer": "5.1.1" 1089 | } 1090 | }, 1091 | "tar-fs": { 1092 | "version": "1.15.3", 1093 | "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-1.15.3.tgz", 1094 | "integrity": "sha1-7M+TXpQUk9gVECjmNuUc5MPKfyA=", 1095 | "requires": { 1096 | "chownr": "1.0.1", 1097 | "mkdirp": "0.5.1", 1098 | "pump": "1.0.2", 1099 | "tar-stream": "1.5.4" 1100 | } 1101 | }, 1102 | "tar-stream": { 1103 | "version": "1.5.4", 1104 | "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.5.4.tgz", 1105 | "integrity": "sha1-NlSc8E7RrumyowwBQyUiONr5QBY=", 1106 | "requires": { 1107 | "bl": "1.2.1", 1108 | "end-of-stream": "1.4.0", 1109 | "readable-stream": "2.3.3", 1110 | "xtend": "4.0.1" 1111 | } 1112 | }, 1113 | "terraformer": { 1114 | "version": "1.0.8", 1115 | "resolved": "https://registry.npmjs.org/terraformer/-/terraformer-1.0.8.tgz", 1116 | "integrity": "sha1-UeCtiXRvzyFh3G9lqnDkI3fItZM=", 1117 | "requires": { 1118 | "@types/geojson": "1.0.4" 1119 | } 1120 | }, 1121 | "terraformer-wkt-parser": { 1122 | "version": "1.1.2", 1123 | "resolved": "https://registry.npmjs.org/terraformer-wkt-parser/-/terraformer-wkt-parser-1.1.2.tgz", 1124 | "integrity": "sha1-M2oMj8gglKWv+DKI9prt7NNpvww=", 1125 | "requires": { 1126 | "terraformer": "1.0.8" 1127 | } 1128 | }, 1129 | "through": { 1130 | "version": "2.3.8", 1131 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 1132 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 1133 | }, 1134 | "through2": { 1135 | "version": "2.0.3", 1136 | "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", 1137 | "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", 1138 | "requires": { 1139 | "readable-stream": "2.3.3", 1140 | "xtend": "4.0.1" 1141 | } 1142 | }, 1143 | "thunkify": { 1144 | "version": "2.1.2", 1145 | "resolved": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz", 1146 | "integrity": "sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=" 1147 | }, 1148 | "toposort-class": { 1149 | "version": "1.0.1", 1150 | "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", 1151 | "integrity": "sha1-f/0feMi+KMO6Rc1OGj9e4ZO9mYg=" 1152 | }, 1153 | "type-check": { 1154 | "version": "0.3.2", 1155 | "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", 1156 | "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", 1157 | "requires": { 1158 | "prelude-ls": "1.1.2" 1159 | } 1160 | }, 1161 | "unpipe": { 1162 | "version": "1.0.0", 1163 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 1164 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 1165 | }, 1166 | "url": { 1167 | "version": "0.10.3", 1168 | "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", 1169 | "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", 1170 | "requires": { 1171 | "punycode": "1.3.2", 1172 | "querystring": "0.2.0" 1173 | } 1174 | }, 1175 | "util-deprecate": { 1176 | "version": "1.0.2", 1177 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 1178 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" 1179 | }, 1180 | "uuid": { 1181 | "version": "3.1.0", 1182 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", 1183 | "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" 1184 | }, 1185 | "validator": { 1186 | "version": "8.2.0", 1187 | "resolved": "https://registry.npmjs.org/validator/-/validator-8.2.0.tgz", 1188 | "integrity": "sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA==" 1189 | }, 1190 | "walkdir": { 1191 | "version": "0.0.11", 1192 | "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.0.11.tgz", 1193 | "integrity": "sha1-oW0CXrkxvQO1LzCMrtD0D86+lTI=" 1194 | }, 1195 | "wkx": { 1196 | "version": "0.4.2", 1197 | "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.4.2.tgz", 1198 | "integrity": "sha1-d201pjSlwi5lbkdEvetU+D/Szo0=", 1199 | "requires": { 1200 | "@types/node": "8.0.31" 1201 | } 1202 | }, 1203 | "wordwrap": { 1204 | "version": "1.0.0", 1205 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", 1206 | "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" 1207 | }, 1208 | "wrappy": { 1209 | "version": "1.0.2", 1210 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 1211 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 1212 | }, 1213 | "xml2js": { 1214 | "version": "0.4.17", 1215 | "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.17.tgz", 1216 | "integrity": "sha1-F76T6q4/O3eTWceVtBlwWogX6Gg=", 1217 | "requires": { 1218 | "sax": "1.2.1", 1219 | "xmlbuilder": "4.2.1" 1220 | } 1221 | }, 1222 | "xmlbuilder": { 1223 | "version": "4.2.1", 1224 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz", 1225 | "integrity": "sha1-qlijBBoGb5DqoWwvU4n/GfP0YaU=", 1226 | "requires": { 1227 | "lodash": "4.17.4" 1228 | } 1229 | }, 1230 | "xregexp": { 1231 | "version": "2.0.0", 1232 | "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", 1233 | "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=" 1234 | }, 1235 | "xtend": { 1236 | "version": "4.0.1", 1237 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", 1238 | "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" 1239 | }, 1240 | "zip-stream": { 1241 | "version": "1.2.0", 1242 | "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.2.0.tgz", 1243 | "integrity": "sha1-qLxF9MG0lpnGuQGYuqyqzbzUugQ=", 1244 | "requires": { 1245 | "archiver-utils": "1.3.0", 1246 | "compress-commons": "1.2.0", 1247 | "lodash": "4.17.4", 1248 | "readable-stream": "2.3.3" 1249 | } 1250 | } 1251 | } 1252 | } 1253 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-events-api-message-handler", 3 | "version": "1.0.0", 4 | "description": "Validates and handles incoming events from Slack", 5 | "main": "index.js", 6 | "dependencies": { 7 | "claudia": "^2.14.2", 8 | "pg": "^6.4.2", 9 | "request": "^2.83.0", 10 | "sequelize": "^4.13.2" 11 | }, 12 | "devDependencies": { 13 | "claudia": "^2.14.2" 14 | }, 15 | "scripts": { 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "keywords": [], 19 | "author": "Quartz", 20 | "license": "ISC" 21 | } 22 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/src/handle-shared-file.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. 6 | function handleSharedFile(slackEvent) { 7 | return new Promise ((resolve, reject) => { 8 | 9 | console.log("Handling this file data:", slackEvent); 10 | 11 | // const fileData = { 12 | // token: slackEvent.authorization.bot_access_token, 13 | // file: slackEvent.file., 14 | // text: reply, 15 | // }; 16 | // const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 17 | // 18 | // console.log("Prepared to send this to slack: ", requestUrl); 19 | // 20 | // // Send message. 21 | // https.get(requestUrl, (res) => { 22 | // resolve(`OK: responded, slack gave ${res.statusCode}`); 23 | // }).on('error', (err) => { 24 | // reject(`ERROR: responded, but slack gave ${err.message}`); 25 | // 26 | // }); 27 | 28 | }); 29 | } 30 | 31 | module.exports = handleSharedFile; 32 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/src/lambda-invoke-function.js: -------------------------------------------------------------------------------- 1 | const Lambda = require('aws-sdk/clients/lambda'); 2 | 3 | const lambda = new Lambda(); 4 | 5 | function invokeLambdaFunction(payload, functionName) { 6 | const lambdaOptions = { 7 | FunctionName: functionName, 8 | InvocationType: 'Event', 9 | Payload: JSON.stringify(payload), 10 | }; 11 | 12 | // Omitting the Qualifier property results in running the $LATEST version. 13 | if (payload.stage !== 'latest') { 14 | lambdaOptions.Qualifier = payload.stage; 15 | } 16 | 17 | return new Promise((resolve, reject) => { 18 | lambda.invoke(lambdaOptions, (error, data) => { 19 | if (error) { 20 | reject(error); 21 | return; 22 | } 23 | 24 | resolve(); 25 | }); 26 | }); 27 | } 28 | 29 | module.exports = invokeLambdaFunction; 30 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/src/process-with-nlp.js: -------------------------------------------------------------------------------- 1 | const request = require('request'); 2 | 3 | // This function requires the original event since replying in the same channel 4 | // is the only supported flow. 5 | function processWithNLP(slackEvent) { 6 | return new Promise ((resolve, reject) => { 7 | 8 | console.log("Sending to API.ai for processing: \n", slackEvent); 9 | 10 | // slack links can arrive like this 11 | // or this ... so pulling out 12 | // the core link in either case. 13 | // also remove users and channels <@UABC123555> 14 | // even at the end of a line 15 | // and trim 16 | const text_to_send = slackEvent.text.replace(/\|.*>/,'').replace(//,'http$1').replace(/<@\S*>[ $]?/,'').trim(); 17 | 18 | const content = { 19 | "query": text_to_send, 20 | "timezone": "America/New_York", 21 | "lang": "en", 22 | "sessionId": slackEvent.user 23 | }; 24 | 25 | request.post({ 26 | url: 'https://api.api.ai/v1/query?v=20150910', 27 | headers: { 28 | "Authorization": "Bearer " + slackEvent.env.API_AI_TOKEN 29 | }, 30 | body: content, 31 | json: true 32 | }, function(error, response, body){ 33 | if (error) { 34 | reject(null); 35 | console.log(error); 36 | return; 37 | } 38 | 39 | console.log("API.AI returned: \n", response); 40 | resolve(body.result); 41 | return; 42 | }); 43 | 44 | }); 45 | } 46 | 47 | module.exports = processWithNLP; 48 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/src/respond-on-error.js: -------------------------------------------------------------------------------- 1 | const invokeLambdaFunction = require('../src/lambda-invoke-function'); 2 | const sendToSlack = require('../src/slack-send-message'); 3 | 4 | function respondOnError(event, errorMessage) { 5 | const payload = { 6 | message: errorMessage, 7 | }; 8 | 9 | console.error(`Responding to user with error: ${errorMessage}`); 10 | 11 | sendToSlack(event, errorMessage); 12 | 13 | return Promise.resolve('OK'); 14 | } 15 | 16 | module.exports = respondOnError; 17 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/src/route-message.js: -------------------------------------------------------------------------------- 1 | const invokeLambdaFunction = require('../src/lambda-invoke-function'); 2 | 3 | function routeMessage(event) { 4 | 5 | const supportedCommands = require('../commands'); 6 | 7 | // Command verb not found. 8 | if (Object.keys(supportedCommands).indexOf(event.command.verb) === -1) { 9 | console.log(`Action/verb "${event.command.verb}" not in the command.js list. Ending silently.`); 10 | return Promise.resolve('OK'); 11 | } 12 | 13 | const route = supportedCommands[event.command.verb]; 14 | 15 | // Check against channel whitelist. 16 | if (route.channelWhitelist && route.channelWhitelist.indexOf(event.channel_name) === -1) { 17 | const channels = route.channelWhitelist.map(channel => `#${channel}`).join(', '); 18 | return Promise.reject(`This command can only be run in the following channels: ${channels}`); 19 | } 20 | 21 | // Check against user whitelist. 22 | if (route.userWhitelist && route.userWhitelist.indexOf(event.user_name) === -1) { 23 | return Promise.reject('You are not authorized to run this command.'); 24 | } 25 | 26 | // Make sure it passes validation before proceeding. 27 | if (route.validation && !route.validation.test(event.command.predicate)) { 28 | if (route.usage) { 29 | return Promise.reject(`Usage: \`${route.usage}\``); 30 | } 31 | 32 | return Promise.reject('Your message didn’t match the expected format.'); 33 | } 34 | 35 | if (route.type === 'lambda') { 36 | console.log(`Routing event to ${route.functionName}....\n`, event); 37 | return invokeLambdaFunction(event, route.functionName); 38 | } 39 | 40 | return Promise.reject('Sorry, I’m having trouble routing your request.'); 41 | } 42 | 43 | module.exports = routeMessage; 44 | -------------------------------------------------------------------------------- /slack-events-api-message-handler/src/slack-send-message.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('querystring'); 3 | 4 | // This function requires the original event since replying in the same channel 5 | // is the only supported flow. 6 | function sendToSlack(slackEvent, reply) { 7 | return new Promise ((resolve, reject) => { 8 | 9 | if (!reply) { 10 | reject('OK: nothing to say'); 11 | } 12 | 13 | console.log("Handling this event for sending back:", slackEvent); 14 | 15 | const slackMessage = { 16 | token: slackEvent.authorization.bot_access_token, 17 | channel: slackEvent.channel, 18 | text: reply, 19 | }; 20 | const requestUrl = `https://slack.com/api/chat.postMessage?${qs.stringify(slackMessage)}`; 21 | 22 | console.log("Prepared to send this to slack: ", requestUrl); 23 | 24 | // Send message. 25 | https.get(requestUrl, (res) => { 26 | resolve(`OK: responded, slack gave ${res.statusCode}`); 27 | }).on('error', (err) => { 28 | reject(`ERROR: responded, but slack gave ${err.message}`); 29 | 30 | }); 31 | }); 32 | } 33 | 34 | module.exports = sendToSlack; 35 | -------------------------------------------------------------------------------- /slack-slash-command-router/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "rules": { 4 | "no-console": 0 5 | } 6 | } -------------------------------------------------------------------------------- /slack-slash-command-router/commands.js: -------------------------------------------------------------------------------- 1 | // For SNS delivery, use the following format. 2 | // { 3 | // type: 'sns', 4 | // topicArn: 'arn:aws:sns:us-east-1:account:topic', 5 | // } 6 | 7 | module.exports = { 8 | kudos: { 9 | type: 'lambda', 10 | botName: 'qzbot', 11 | functionName: 'slack-slash-command-qzbot-kudos', 12 | }, 13 | ping: { 14 | type: 'lambda', 15 | botName: 'qzbot', 16 | functionName: 'slack-slash-command-qzbot-ping', 17 | }, 18 | screenshot: { 19 | type: 'lambda', 20 | botName: 'qzbot', 21 | functionName: 'slack-slash-command-qzbot-screenshot', 22 | reply: { 23 | response_type: 'in_channel', 24 | text: ':frame_with_picture: Generating your screenshot....', 25 | }, 26 | }, 27 | cliches: { 28 | type: 'lambda', 29 | botName: 'quack', 30 | functionName: 'botstudio-cliche-finder', 31 | reply: { 32 | response_type: 'in_channel', 33 | text: 'Checking that website....', 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /slack-slash-command-router/index.js: -------------------------------------------------------------------------------- 1 | const ApiBuilder = require('claudia-api-builder'); 2 | const postMessage = require('./routes/messages-post'); 3 | 4 | const api = new ApiBuilder(); 5 | 6 | api.post('/messages', postMessage.bind(null, api)); 7 | 8 | module.exports = api; 9 | -------------------------------------------------------------------------------- /slack-slash-command-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-slash-command-qzbot-router", 3 | "version": "1.0.0", 4 | "description": "Provides a connector between a Slack slash command and AWS SNS, powered by AWS Lambda", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint **/*.js" 8 | }, 9 | "author": "Chris Zarate", 10 | "license": "ISC", 11 | "dependencies": { 12 | "aws-sdk": "^2.45.0", 13 | "claudia-api-builder": "^2.4.1" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^3.19.0", 17 | "eslint-config-airbnb-base": "^11.1.3", 18 | "eslint-plugin-import": "^2.2.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /slack-slash-command-router/readme.md: -------------------------------------------------------------------------------- 1 | # Slack Messages to AWS Lambda 2 | 3 | This repo provides a Slack slash command endpoint on AWS API Gateway and Lambda 4 | to process slash command messages. It parses the response and then either 5 | invokes another Lambda function or publishes a message to SNS (allowing it to be 6 | processed by something else). 7 | 8 | ## Creating and updating the endpoint 9 | 10 | ``` 11 | claudia create --api-module index 12 | ``` 13 | 14 | [Claudia][claudia] will print out the endpoint base URL. Append `/messages` to 15 | the end and provide it to Slack in the "Slash Commands" section of your app 16 | configuration. 17 | 18 | Update the endpoint with: 19 | 20 | ``` 21 | claudia update 22 | ``` 23 | 24 | ## Add a verification token as stage variable 25 | 26 | In the AWS web console, select your endpoint, select the stage, open the "Stage 27 | Variables" tab, and add a variable named `slackAppVerificationToken`. The value 28 | should be your app's verification token, which can be found on the "Basic 29 | Information" page of your app's settings. 30 | 31 | Or run the following AWS CLI command (using the `jq` utility to extract the API 32 | Gateway ID): 33 | 34 | ``` 35 | aws apigateway create-deployment \ 36 | --rest-api-id $(jq -r '.api.id' claudia.json) \ 37 | --stage-name latest \ 38 | --variables slackAppVerificationToken=mytoken 39 | ``` 40 | 41 | ## Enabling commands 42 | 43 | This endpoint expects to receive messages in a specific format: 44 | 45 | ``` 46 | /command verb predicate which can be zero or more words 47 | ``` 48 | 49 | Add your verb to `commands.js`. 50 | 51 | ## That's it? 52 | 53 | That's it. But now you can write any number of Lambda functions or other code 54 | to respond to these commands. 55 | 56 | ## Using API Gateway stages 57 | 58 | One way to manage testing—for both this router and the Lambda functions it 59 | calls—is to create different "stages" on a single API Gateway endpoint and 60 | provide them to two different Slack teams: a "testing" Slack team and a "prod" 61 | Slack team. This allows you to avoid multiple separate deployments of your 62 | Lambda functions. 63 | 64 | When you initially created your endpoint, your one and only stage is named 65 | "latest" (the default). Notice that the stage name is included in the endpoint 66 | URL. Provide this endpoint to a "testing" Slack team. This is your dev tier. 67 | 68 | Create a new stage named "prod": 69 | 70 | ``` 71 | claudia set-version --version prod 72 | ``` 73 | 74 | This is your prod tier, which will invoke the "prod" qualifier / alias of your 75 | router Lambda function (which the above command also created). You also must 76 | provide the verification token from your "prod" Slack team as a stage variable, 77 | as outlined in the section "Add a verification token as stage variable" above 78 | (the token is different for each Slack team). 79 | 80 | Using `claudia set-version`, you will also need to create the "prod" alias for 81 | each Lambda function you wish to invoke via the router. The pattern will be 82 | consistent: The "latest" stage of the API Gateway invokes the "latest" version 83 | of the router function, which invokes the "latest" version command functions. 84 | Similarly, the "prod" stage invokes the "prod" versions of the router and 85 | command functions. This allows you to run bleeding-edge code in your "testing" 86 | Slack team and reliable code in your "prod" Slack team. 87 | 88 | Use the same flow for updating *both the router and command functions*: 89 | 90 | 1. Update your code. 91 | 92 | 2. Run `claudia update`, which automatically updates the "latest" alias. 93 | 94 | 3. Test in your "testing" Slack. 95 | 96 | 4. Run `claudia set-version --version prod` to update the "prod" alias. 97 | 98 | [app-config]: https://api.slack.com/slack-apps 99 | [claudia]: https://claudiajs.com 100 | -------------------------------------------------------------------------------- /slack-slash-command-router/routes/messages-post.js: -------------------------------------------------------------------------------- 1 | const queryString = require('querystring'); 2 | const processMessage = require('../src/process-message'); 3 | const routeMessage = require('../src/route-message'); 4 | 5 | function route(api, request) { 6 | return new Promise((resolve) => { 7 | // The message is POSTed to our endpoint as form data. 8 | const message = queryString.parse(request.body); 9 | 10 | // Slack sends a verification token with each request. We use this to verify 11 | // that the message is really coming from Slack and not someone else that 12 | // found our endpoint. The verification token is different for each 13 | // instance of Slack and can be found on the "Basic Information" page of the 14 | // app settings. 15 | if (message.token !== request.env.slackAppVerificationToken) { 16 | throw new Error('Invalid app verification token.'); 17 | } 18 | 19 | // Add API Gateway stage to message. We'll need this to determine where to 20 | // route the message. 21 | console.log(`Message posted to ${request.context.stage} stage.`); 22 | message.stage = request.context.stage; 23 | 24 | resolve(processMessage(message)); 25 | }) 26 | .then(routeMessage) 27 | .then((response) => { 28 | // Returning an object means that API Gateway should respond to Slack with 29 | // 200 and with that object as the response body. This is how we respond 30 | // to the slash command. 31 | 32 | // If the response is a string, we've been given an immediate response. Post 33 | // it publicly in the channel. 34 | if (response && typeof response === 'string') { 35 | console.log(`Responding publicly with: ${response}`); 36 | 37 | return { 38 | response_type: 'in_channel', 39 | text: response, 40 | }; 41 | } 42 | 43 | // If the response is an object, assume the response is an Slack message 44 | // object and pass it through. 45 | if (response && typeof response === 'object') { 46 | console.log('Passing response through....', response); 47 | return response; 48 | } 49 | 50 | // If there the response is falsy, we published to an SNS topic and we 51 | // don't actually want to respond to the user yet. Send an empty response. 52 | // This lets Slack know that we've acknowledged the command. 53 | return new api.ApiResponse('', { 'Content-Type': 'text/plain' }, 200); 54 | }) 55 | .catch((error) => { 56 | console.error(error); 57 | 58 | // Something went wrong. Provide an private response to the user. 59 | return Promise.resolve({ 60 | response_type: 'ephemeral', 61 | text: error.message || 'An unknown error occurred', 62 | }); 63 | }); 64 | } 65 | 66 | module.exports = route; 67 | -------------------------------------------------------------------------------- /slack-slash-command-router/src/lambda-invoke-function.js: -------------------------------------------------------------------------------- 1 | const Lambda = require('aws-sdk/clients/lambda'); 2 | 3 | const lambda = new Lambda(); 4 | 5 | function invokeLambdaFunction(payload, functionName, triggerAsync = false) { 6 | const lambdaOptions = { 7 | FunctionName: functionName, 8 | Payload: JSON.stringify(payload), 9 | }; 10 | 11 | // Omitting the Qualifier property results in running the $LATEST version. 12 | if (payload.stage !== 'latest') { 13 | lambdaOptions.Qualifier = payload.stage; 14 | } 15 | 16 | // Async functions might take longer to respond than our timeout will allow. 17 | // Invoke them as an event and don't wait for a response. 18 | if (triggerAsync) { 19 | lambdaOptions.InvocationType = 'Event'; 20 | } 21 | 22 | return new Promise((resolve, reject) => { 23 | lambda.invoke(lambdaOptions, (error, data) => { 24 | if (error) { 25 | reject(error); 26 | return; 27 | } 28 | 29 | console.log('Received Lambda response....', data); 30 | 31 | try { 32 | // An empty payload corresponds to an event invocation. A syncronous 33 | // invocation may also choose to return an empty payload to signal that 34 | // it intends to respond later. 35 | if (data.Payload === '') { 36 | resolve(null); 37 | return; 38 | } 39 | 40 | resolve(JSON.parse(data.Payload)); 41 | } catch (parseError) { 42 | reject(new Error('Could not parse Lambda function response')); 43 | } 44 | }); 45 | }); 46 | } 47 | 48 | module.exports = invokeLambdaFunction; 49 | -------------------------------------------------------------------------------- /slack-slash-command-router/src/process-message.js: -------------------------------------------------------------------------------- 1 | const supportedCommands = require('../commands'); 2 | 3 | function processMessage(message) { 4 | // Extract command. 5 | const commandWords = message.text.split(/\s+/); 6 | 7 | // This object will be added to the message so that invoked functions don't 8 | // have to parse the command themselves. 9 | const command = { 10 | subject: message.command.replace(/^\//, '').replace(/\s+/, ''), 11 | predicate: commandWords.splice(1).join(' '), 12 | verb: commandWords[0], 13 | }; 14 | 15 | // Command verb not found. 16 | if (Object.keys(supportedCommands).indexOf(command.verb) === -1) { 17 | throw new Error(`Sorry, I don’t know how to respond to “${command.verb}.”`); 18 | } 19 | 20 | const route = supportedCommands[command.verb]; 21 | 22 | // Check against channel whitelist. 23 | if (route.channelWhitelist && route.channelWhitelist.indexOf(message.channel_name) === -1) { 24 | const channels = route.channelWhitelist.map(channel => `#${channel}`).join(', '); 25 | throw new Error(`This command can only be run in the following channels: ${channels}`); 26 | } 27 | 28 | // Check against user whitelist. 29 | if (route.userWhitelist && route.userWhitelist.indexOf(message.user_name) === -1) { 30 | throw new Error('You are not authorized to run this command.'); 31 | } 32 | 33 | // Command verb found, but it should be used with a different slash command. 34 | if (route.botName !== command.subject) { 35 | throw new Error(`You might want to ask \`/${route.botName}\` about that!`); 36 | } 37 | 38 | // Make sure it passes validation before proceeding. 39 | if (route.validation && !route.validation.test(command.predicate)) { 40 | if (route.usage) { 41 | throw new Error(`Usage: \`/${route.botName} ${command.verb} ${route.usage}\``); 42 | } 43 | 44 | throw new Error('Your message didn’t match the expected format.'); 45 | } 46 | 47 | // Add command words to the message to form final message. Delete token out of 48 | // pure paranoia. 49 | const processedMessage = Object.assign(message, { command }); 50 | delete processedMessage.token; 51 | 52 | return processedMessage; 53 | } 54 | 55 | module.exports = processMessage; 56 | -------------------------------------------------------------------------------- /slack-slash-command-router/src/route-message.js: -------------------------------------------------------------------------------- 1 | const invokeLambdaFunction = require('../src/lambda-invoke-function'); 2 | const publishSnsMessage = require('../src/sns-publish-message'); 3 | const supportedCommands = require('../commands'); 4 | 5 | function routeMessage(message) { 6 | // This object describes what we should do with the command. 7 | const commandRoute = supportedCommands[message.command.verb]; 8 | 9 | if (commandRoute.type === 'lambda') { 10 | return invokeLambdaFunction(message, commandRoute.functionName, !!commandRoute.reply) 11 | .then(response => commandRoute.reply || response); 12 | } 13 | 14 | if (commandRoute.type === 'sns') { 15 | return publishSnsMessage(message, commandRoute.topicArn).then(() => commandRoute.reply || null); 16 | } 17 | 18 | throw new Error('Sorry, I’m having trouble routing your request.'); 19 | } 20 | 21 | module.exports = routeMessage; 22 | -------------------------------------------------------------------------------- /slack-slash-command-router/src/sns-publish-message.js: -------------------------------------------------------------------------------- 1 | const SNS = require('aws-sdk/clients/sns'); 2 | 3 | const sns = new SNS(); 4 | 5 | function publishSnsMessage(snsMessage, topicArn) { 6 | const payload = { 7 | Message: JSON.stringify(snsMessage), 8 | TopicArn: topicArn, 9 | }; 10 | 11 | return new Promise((resolve, reject) => { 12 | sns.publish(payload, (error, data) => { 13 | if (error) { 14 | console.error(`Could not publish to ${topicArn}: ${error.message}`); 15 | reject(new Error('Sorry, I’m having trouble routing your request.')); 16 | return; 17 | } 18 | 19 | console.log(`Published SNS message ${data.MessageId} to ${topicArn}.`); 20 | resolve(); 21 | }); 22 | }); 23 | } 24 | 25 | module.exports = publishSnsMessage; 26 | -------------------------------------------------------------------------------- /slack-slash-commands/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/slack-slash-commands/.DS_Store -------------------------------------------------------------------------------- /slack-slash-commands/examples/delayed-response/index.js: -------------------------------------------------------------------------------- 1 | const getSnsMessage = require('./src/lambda-get-sns-message'); 2 | const replyToSlashCommand = require('./src/slack-reply-to-slash-command'); 3 | 4 | // This function generates a reply for Slack. 5 | function generateReply(slackMessage) { 6 | return { 7 | response_type: 'ephemeral', 8 | text: `<@${slackMessage.user_name}> pong ${slackMessage.command.predicate}`, 9 | }; 10 | } 11 | 12 | exports.handler = (lambdaEvent, context, callback) => { 13 | const slackMessage = getSnsMessage(lambdaEvent); 14 | const reply = generateReply(slackMessage); 15 | 16 | // The callback parameter is how we let AWS Lambda know that we are done 17 | // processing. 18 | replyToSlashCommand(slackMessage, reply).then(callback).catch(callback); 19 | }; 20 | -------------------------------------------------------------------------------- /slack-slash-commands/examples/delayed-response/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-slash-command-qzbot-test-delayed-response", 3 | "version": "1.0.0", 4 | "description": "qzbot ping", 5 | "main": "index.js", 6 | "author": "Chris Zarate", 7 | "license": "ISC" 8 | } 9 | -------------------------------------------------------------------------------- /slack-slash-commands/examples/delayed-response/readme.md: -------------------------------------------------------------------------------- 1 | # An example Lambda function for a decoupled Slack bot with delayed response 2 | 3 | Most bots that are Lambda functions can just be wired up directly and respond 4 | immediately. Slack requires immediate responses to be sent in less than three 5 | seconds—sometimes you might need more time. 6 | 7 | 1. Pick a command that you want your bot to respond to. For example, you might 8 | want your bot to respond when users type `/mybot ping`. 9 | 10 | 2. Edit `index.js` to do something in response to that command. 11 | 12 | 3. Create a SNS topic corresponding to that command and subscribe your Lambda 13 | function to it. 14 | 15 | You might need help with step 3—just ask your friendly neighborhood #bots 16 | channel! 17 | 18 | ## Events 19 | 20 | See [Slack’s documentation][slack-slash-docs] for an example of what’s included 21 | in a Slack message. 22 | 23 | Since this code is only triggered if a user types a specific command, e.g., 24 | `/mybot ping hello!`, some extra information is added to the event to help you 25 | respond: 26 | 27 | ``` 28 | { 29 | ... 30 | command: { 31 | verb: 'ping', 32 | predicate: 'hello!' 33 | } 34 | } 35 | ``` 36 | 37 | [slack-slash-docs]: https://api.slack.com/slash-commands 38 | -------------------------------------------------------------------------------- /slack-slash-commands/examples/delayed-response/src/lambda-get-sns-message.js: -------------------------------------------------------------------------------- 1 | // Extract an SNS message as passed to Lambda. 2 | function getSnsMessage(lambdaEvent) { 3 | let message; 4 | 5 | try { 6 | message = JSON.parse(lambdaEvent.Records[0].Sns.Message); 7 | } catch (error) { 8 | throw new Error('Unable to extract SNS message'); 9 | } 10 | 11 | console.log('Received SNS message:', message); 12 | return message; 13 | } 14 | 15 | module.exports = getSnsMessage; 16 | -------------------------------------------------------------------------------- /slack-slash-commands/examples/delayed-response/src/slack-reply-to-slash-command.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const url = require('url'); 3 | 4 | // This function requires the original message since using the provided 5 | // response_url is the only supported flow. 6 | function replyToSlashCommand(originalMessage, reply) { 7 | const postData = JSON.stringify(reply); 8 | const urlParts = url.parse(originalMessage.response_url); 9 | 10 | const options = { 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | 'Content-Length': Buffer.byteLength(postData), 14 | }, 15 | hostname: urlParts.host, 16 | method: 'POST', 17 | path: urlParts.path, 18 | port: urlParts.port || 443, 19 | }; 20 | 21 | return new Promise((resolve, reject) => { 22 | const req = https.request(options, (res) => { 23 | console.log(`OK: Slack responded with ${res.statusCode}`); 24 | resolve(); 25 | }); 26 | 27 | req.on('error', (err) => { 28 | console.log(`Slack responded with ${err.message}`); 29 | reject(err); 30 | }); 31 | 32 | req.write(postData); 33 | req.end(); 34 | }); 35 | } 36 | 37 | module.exports = replyToSlashCommand; 38 | -------------------------------------------------------------------------------- /slack-slash-commands/ping/index.js: -------------------------------------------------------------------------------- 1 | // This function generates a reply for Slack. The response type can be 2 | // "in_channel" (visible to all users) or "ephemeral" (only visible to the user 3 | // who sent the command). 4 | function generateReply(slackMessage) { 5 | return { 6 | response_type: 'in_channel', 7 | text: `pong ${slackMessage.command.predicate}`, 8 | }; 9 | } 10 | 11 | exports.handler = (lambdaEvent, context, callback) => { 12 | const reply = generateReply(lambdaEvent); 13 | console.log('Generating reply....', reply); 14 | 15 | // We pass our reply to the callback function. 16 | callback(null, reply); 17 | }; 18 | -------------------------------------------------------------------------------- /slack-slash-commands/ping/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-slash-command-qzbot-ping", 3 | "version": "1.0.0", 4 | "description": "qzbot ping", 5 | "main": "index.js", 6 | "author": "Chris Zarate", 7 | "license": "ISC" 8 | } 9 | -------------------------------------------------------------------------------- /slack-slash-commands/ping/readme.md: -------------------------------------------------------------------------------- 1 | # An example Lambda function for a decoupled Slack bot 2 | 3 | 1. Pick a command that you want your bot to respond to. For example, you might 4 | want your bot to respond when users type `/mybot ping`. 5 | 6 | 2. Edit `index.js` to do something in response to that command. 7 | 8 | 3. Ask your friendly neighborhood #bots channel to wire up your Lambda function 9 | to the bot router. 10 | 11 | ## Events 12 | 13 | See [Slack’s documentation][slack-slash-docs] for an example of what’s included 14 | in a Slack message. 15 | 16 | Since this code is only triggered if a user types a specific command, e.g., 17 | `/mybot ping hello!`, some extra information is added to the event to help you 18 | respond: 19 | 20 | ``` 21 | { 22 | ... 23 | command: { 24 | verb: 'ping', 25 | predicate: 'hello!' 26 | } 27 | } 28 | ``` 29 | 30 | [slack-slash-docs]: https://api.slack.com/slash-commands 31 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/slack-slash-commands/screenshot/.DS_Store -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "rules": { 4 | "no-console": 0 5 | } 6 | } -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/slack-slash-commands/screenshot/README.md -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/config.js: -------------------------------------------------------------------------------- 1 | const port = 9222; 2 | 3 | const chromeFlags = [ 4 | '--headless', 5 | '--disable-gpu', 6 | '--no-sandbox', 7 | '--user-data-dir=/tmp/user-data', 8 | '--hide-scrollbars', 9 | '--enable-logging', 10 | '--log-level=0', 11 | '--v=99', 12 | '--single-process', 13 | '--data-path=/tmp/data-path', 14 | `--remote-debugging-port=${port}`, 15 | '--ignore-certificate-errors', 16 | '--homedir=/tmp', 17 | '--disk-cache-dir=/tmp/cache-dir', 18 | ]; 19 | 20 | const chrome = { 21 | headlessPort: port, 22 | headlessUrl: `http://127.0.0.1:${port}`, 23 | pageLoadTimeout: 1000 * 60, 24 | path: '/tmp/headless-chrome/headless_shell', 25 | startupTimeout: 1000 * 10, 26 | }; 27 | 28 | const customizations = { 29 | 'twitter.com': { 30 | cropElement: '.permalink-tweet', 31 | stylesheet: 'inject/css/twitter.css', 32 | } 33 | }; 34 | 35 | const s3 = { 36 | bucket: 'qz-screenshots', 37 | cloudfront: 'https://d1gdla0ognurmx.cloudfront.net', 38 | }; 39 | 40 | // `fromSurface: true` is needed on OS X. 41 | const screenshot = { 42 | format: 'png', 43 | timeout: 5000, 44 | }; 45 | 46 | module.exports = { 47 | chromeFlags, 48 | chrome, 49 | customizations, 50 | s3, 51 | screenshot, 52 | }; 53 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/index.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const lambdaChrome = require('lambda-chrome'); 3 | const captureScreenshot = require('./src/capture-screenshot'); 4 | const replyToSlashCommand = require('./src/slack-reply-to-slash-command'); 5 | 6 | function generateReply(s3Response, slackMessage) { 7 | return { 8 | response_type: 'in_channel', 9 | attachments: [ 10 | { 11 | fallback: 'Generated screenshot', 12 | image_url: `${config.s3.cloudfront}/${s3Response.key}`, 13 | footer: `Generated screenshot of ${slackMessage.command.predicate}`, 14 | }, 15 | ], 16 | }; 17 | } 18 | 19 | exports.handler = function (slackMessage, context, callback) { 20 | console.log('Received Slack message....', slackMessage); 21 | 22 | if (!slackMessage.command.predicate) { 23 | callback(new Error('No screenshot URL provided')); 24 | return; 25 | } 26 | 27 | const url = slackMessage.command.predicate.replace(/^$/, ''); 28 | 29 | lambdaChrome() 30 | .then(client => captureScreenshot(client, url)) 31 | .then(s3Response => { 32 | console.log('Generated screenshot....', s3Response); 33 | return replyToSlashCommand(slackMessage, generateReply(s3Response, slackMessage)); 34 | }) 35 | .then(() => { 36 | callback(null, 'Responded to Slack.'); 37 | }) 38 | .catch(err => { 39 | console.error(err); 40 | callback(new Error('Could not generate screenshot.')); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-slash-command-qzbot-screenshot", 3 | "version": "1.0.0", 4 | "description": "qzbot screenshot", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint **/*.js" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "aws-sdk": "^2.52.0", 13 | "gm": "^1.23.0", 14 | "lambda-chrome": "chriszarate/aws-lambda-headless-chrome" 15 | }, 16 | "devDependencies": { 17 | "eslint": "^3.19.0", 18 | "eslint-config-airbnb-base": "^11.1.3", 19 | "eslint-plugin-import": "^2.2.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/capture-screenshot.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const gm = require('gm').subClass({ imageMagick: true }); 3 | 4 | const config = require('../config'); 5 | const cropScreenshot = require('./crop-screenshot'); 6 | const getElementRect = require('./get-element-rect'); 7 | const injectStylesheet = require('./inject-stylesheet'); 8 | const saveImage = require('./save-image'); 9 | const { delay } = require('./utils'); 10 | 11 | // Set up viewport resolution, etc. 12 | const deviceMetrics = { 13 | width: 1200, 14 | height: 1000, 15 | deviceScaleFactor: 2, 16 | mobile: false, 17 | fitWindow: true, 18 | }; 19 | 20 | function saveToS3(buffer, url) { 21 | // Generate hash suffix for filename. 22 | const hash = crypto.createHmac('sha512', '41ecde00bfbe5b8ca3b82601b749bdf3'); 23 | hash.update(url); 24 | 25 | const s3Params = { 26 | ACL: 'public-read', 27 | Body: buffer, 28 | Bucket: config.s3.bucket, 29 | ContentType: 'image/jpeg', 30 | Key: `screenbot/${hash.digest('hex').substr(0, 16)}.jpg`, 31 | }; 32 | 33 | console.log('Saving screenshot to S3....'); 34 | return saveImage(s3Params); 35 | } 36 | 37 | function getCustomizations(url) { 38 | const key = Object.keys(config.customizations).find(domain => url.indexOf(domain) !== -1); 39 | return config.customizations[key] || {}; 40 | } 41 | 42 | function convertScreenshot(buffer) { 43 | console.log('Converting to JPEG....'); 44 | 45 | return new Promise((resolve, reject) => { 46 | gm(buffer).toBuffer('jpg', (error, newBuffer) => { 47 | if (error) { 48 | console.error('Error converting to JPEG....', error); 49 | reject(error); 50 | return; 51 | } 52 | 53 | resolve(newBuffer); 54 | }); 55 | }); 56 | } 57 | 58 | function captureScreenshot(client, url) { 59 | const customizations = getCustomizations(url); 60 | 61 | return new Promise((resolve, reject) => { 62 | const { Emulation, Page, Runtime } = client; 63 | const timeout = setTimeout(reject, config.chrome.pageLoadTimeout); 64 | 65 | const doCrop = (buffer) => { 66 | if (!customizations.cropElement) { 67 | return buffer; 68 | } 69 | 70 | return Runtime.evaluate({ 71 | expression: getElementRect(customizations.cropElement), 72 | returnByValue: true, 73 | }) 74 | .then(response => cropScreenshot(buffer, response)) 75 | .catch(() => Promise.resolve(buffer)); 76 | }; 77 | 78 | const doInjection = () => { 79 | if (!customizations.stylesheet) { 80 | return Promise.resolve(); 81 | } 82 | 83 | return Runtime.evaluate({ 84 | expression: injectStylesheet(customizations.stylesheet), 85 | }); 86 | }; 87 | 88 | const getScreenshotBuffer = () => { 89 | console.log('Taking screenshot....'); 90 | return Page.captureScreenshot(config.screenshot).then(screenshot => Buffer.from(screenshot.data, 'base64')); 91 | }; 92 | 93 | const saveScreenshot = buffer => saveToS3(buffer, url).then((s3Response) => { 94 | client.close().then(() => { 95 | clearTimeout(timeout); 96 | resolve(s3Response); 97 | }); 98 | }); 99 | 100 | Page.loadEventFired(() => { 101 | delay(1000)() 102 | .then(doInjection) 103 | .then(delay(config.screenshot.timeout)) 104 | .then(getScreenshotBuffer) 105 | .then(doCrop) 106 | .then(convertScreenshot) 107 | .then(saveScreenshot); 108 | }); 109 | 110 | [ 111 | Page.enable(), 112 | Runtime.enable(), 113 | Emulation.setDeviceMetricsOverride(deviceMetrics), 114 | Emulation.setVisibleSize({ width: deviceMetrics.width, height: deviceMetrics.height }), 115 | Page.navigate({ url }), 116 | ].reduce((p, fn) => p.then(fn), Promise.resolve()); 117 | }); 118 | } 119 | 120 | module.exports = captureScreenshot; 121 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/crop-screenshot.js: -------------------------------------------------------------------------------- 1 | const gm = require('gm').subClass({ imageMagick: true }); 2 | 3 | function cropScreenshot(buffer, response) { 4 | const rect = response.result.value; 5 | 6 | console.log('Cropping screenshot....'); 7 | return new Promise((resolve, reject) => { 8 | gm(buffer) 9 | .crop(rect.width, rect.height, rect.left, rect.top) 10 | .toBuffer('png', (error, newBuffer) => { 11 | if (error) { 12 | console.error('Error cropping screenshot....', error); 13 | reject(error); 14 | return; 15 | } 16 | 17 | resolve(newBuffer); 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = cropScreenshot; 23 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/get-element-rect.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const inject = (selector) => { 4 | const el = document.querySelector(selector); 5 | const rect = el ? el.getBoundingClientRect() : {}; 6 | 7 | return { 8 | bottom: rect.bottom, 9 | height: rect.height, 10 | left: rect.left, 11 | right: rect.right, 12 | top: rect.top, 13 | width: rect.width, 14 | }; 15 | }; 16 | 17 | function getElementRect(selector) { 18 | console.log(`Getting bounding rect for ${selector}....`); 19 | return `(${inject.toString()})('${selector}')`; 20 | } 21 | 22 | module.exports = getElementRect; 23 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/inject-stylesheet.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const inject = (cssString) => { 7 | const addTo = (doc) => { 8 | const style = doc.createElement('style'); 9 | style.type = 'text/css'; 10 | style.innerHTML = cssString; 11 | 12 | doc.querySelector('head').appendChild(style); 13 | }; 14 | 15 | addTo(document); 16 | 17 | const cardFrame = document.querySelector('.card2 iframe'); 18 | if (cardFrame) { 19 | console.log('Injecting in Twitter card frame....'); 20 | addTo(cardFrame.contentDocument); 21 | } 22 | }; 23 | 24 | function injectStylesheet(stylesheet) { 25 | console.log(`Injecting stylesheet ${stylesheet}....`); 26 | 27 | const data = fs.readFileSync(path.resolve(stylesheet)); 28 | const cssString = data.toString().replace(/\n/g, ''); 29 | const injectString = inject.toString().replace(/\n/g, ''); 30 | 31 | return `(${injectString})('${cssString}')`; 32 | } 33 | 34 | module.exports = injectStylesheet; 35 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/save-image.js: -------------------------------------------------------------------------------- 1 | const S3 = require('aws-sdk/clients/s3'); 2 | 3 | function saveImage(s3Params) { 4 | const s3 = new S3(); 5 | 6 | return new Promise((resolve, reject) => { 7 | s3.putObject(s3Params, (error, data) => { 8 | if (error) { 9 | reject(error); 10 | return; 11 | } 12 | 13 | resolve({ 14 | etag: data.ETag, 15 | key: s3Params.Key, 16 | }); 17 | }); 18 | }); 19 | } 20 | 21 | module.exports = saveImage; 22 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/slack-reply-to-slash-command.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const url = require('url'); 3 | 4 | // This function requires the original message since using the provided 5 | // response_url is the only supported flow. 6 | function replyToSlashCommand(originalMessage, reply) { 7 | const postData = JSON.stringify(reply); 8 | const urlParts = url.parse(originalMessage.response_url); 9 | 10 | const options = { 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | 'Content-Length': Buffer.byteLength(postData), 14 | }, 15 | hostname: urlParts.host, 16 | method: 'POST', 17 | path: urlParts.path, 18 | port: urlParts.port || 443, 19 | }; 20 | 21 | return new Promise((resolve, reject) => { 22 | const req = https.request(options, (res) => { 23 | console.log(`OK: Slack responded with ${res.statusCode}`); 24 | resolve(); 25 | }); 26 | 27 | req.on('error', (err) => { 28 | console.log(`Slack responded with ${err.message}`); 29 | reject(err); 30 | }); 31 | 32 | req.write(postData); 33 | req.end(); 34 | }); 35 | } 36 | 37 | module.exports = replyToSlashCommand; 38 | -------------------------------------------------------------------------------- /slack-slash-commands/screenshot/src/utils.js: -------------------------------------------------------------------------------- 1 | function delay(timeout) { 2 | return value => new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve(value); 5 | }, timeout); 6 | }); 7 | } 8 | 9 | module.exports = { 10 | delay, 11 | }; 12 | -------------------------------------------------------------------------------- /test.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/test.json -------------------------------------------------------------------------------- /utility/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/utility/.DS_Store -------------------------------------------------------------------------------- /utility/screenshot-compositor/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quartz/quackbot/382f0467c84e63b7b4816d002b2e549335d0f0df/utility/screenshot-compositor/.DS_Store -------------------------------------------------------------------------------- /utility/screenshot-compositor/config.js: -------------------------------------------------------------------------------- 1 | const customizations = { 2 | 'twitter.com': { 3 | cropElement: '.permalink-tweet', 4 | stylesheet: 'inject/css/twitter.css', 5 | } 6 | }; 7 | 8 | const s3 = { 9 | bucket: 'qz-screenshots', 10 | cloudfront: 'https://d1gdla0ognurmx.cloudfront.net', 11 | }; 12 | 13 | // `fromSurface: true` is needed on OS X. 14 | const screenshot = { 15 | format: 'png', 16 | timeout: 5000, 17 | }; 18 | 19 | module.exports = { 20 | customizations, 21 | s3, 22 | screenshot, 23 | }; 24 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const captureScreenshot = require('./src/capture-screenshot'); 4 | const combineImages = require('./src/combine-images'); 5 | const config = require('./config'); 6 | const launchChrome = require('./src/launch-chrome'); 7 | const prepareChrome = require('./src/prepare-chrome'); 8 | const saveToS3 = require('./src/save-to-s3'); 9 | 10 | exports.handler = (payload, context, callback) => { 11 | const loadPages = client => { 12 | const { Page } = client; 13 | const images = []; 14 | 15 | const saveBuffer = buffer => { 16 | var path = '/tmp/image' + images.length + '.png'; 17 | 18 | return new Promise((resolve, reject) => { 19 | fs.open(path, 'w', (err, fd) => { 20 | fs.write(fd, buffer, 0, buffer.length, null, err => { 21 | fs.close(fd, function() { 22 | images.push(path); 23 | resolve(); 24 | }); 25 | }); 26 | }); 27 | }); 28 | }; 29 | 30 | return new Promise((resolve, reject) => { 31 | let url; 32 | 33 | const loadPage = () => { 34 | if (!payload.urls.length) { 35 | client.close().then(() => resolve(images)); 36 | return; 37 | } 38 | 39 | url = payload.urls.shift(); 40 | Page.navigate({ url }); 41 | }; 42 | 43 | const onPageLoad = () => captureScreenshot(client, url).then(saveBuffer).then(loadPage); 44 | 45 | Page.loadEventFired(onPageLoad); 46 | loadPage(); 47 | }); 48 | }; 49 | 50 | launchChrome() 51 | .then(client => prepareChrome(client)) 52 | .then(loadPages) 53 | .then(combineImages) 54 | .then(saveToS3) 55 | .then(s3Response => { 56 | console.log('Generated composite....', s3Response); 57 | callback(null, `${config.s3.cloudfront}/${s3Response.key}`); 58 | }) 59 | .catch(err => { 60 | console.error(err); 61 | callback(new Error('Could not generate screenshot.')); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "composite-bot", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Chris Zarate", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@serverless-chrome/lambda": "0.0.0-pre-release-4", 13 | "aws-sdk": "^2.66.0", 14 | "chrome-remote-interface": "^0.23.2", 15 | "gm": "^1.23.0", 16 | "request": "^2.81.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/capture-screenshot.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | const cropScreenshot = require('./crop-screenshot'); 3 | const getElementRect = require('./get-element-rect'); 4 | const injectStylesheet = require('./inject-stylesheet'); 5 | const { delay } = require('./utils'); 6 | 7 | function getCustomizations(url) { 8 | const key = Object.keys(config.customizations).find(domain => url.indexOf(domain) !== -1); 9 | return config.customizations[key] || {}; 10 | } 11 | 12 | function captureScreenshot(client, url) { 13 | return new Promise((resolve, reject) => { 14 | const customizations = getCustomizations(url); 15 | const { Emulation, Page, Runtime } = client; 16 | const timeout = setTimeout(reject, 1000 * 60); 17 | 18 | const doCrop = (buffer) => { 19 | if (!customizations.cropElement) { 20 | return buffer; 21 | } 22 | 23 | return Runtime.evaluate({ 24 | expression: getElementRect(customizations.cropElement), 25 | returnByValue: true, 26 | }) 27 | .then(response => cropScreenshot(buffer, response)) 28 | .catch(() => Promise.resolve(buffer)); 29 | }; 30 | 31 | const doInjection = () => { 32 | if (!customizations.stylesheet) { 33 | return Promise.resolve(); 34 | } 35 | 36 | return Runtime.evaluate({ 37 | expression: injectStylesheet(customizations.stylesheet), 38 | }); 39 | }; 40 | 41 | const getScreenshotBuffer = () => { 42 | console.log('Taking screenshot....'); 43 | return Page.captureScreenshot(config.screenshot).then(screenshot => Buffer.from(screenshot.data, 'base64')); 44 | }; 45 | 46 | delay(1000)() 47 | .then(doInjection) 48 | .then(delay(config.screenshot.timeout)) 49 | .then(getScreenshotBuffer) 50 | .then(doCrop) 51 | .then(buffer => { 52 | clearTimeout(timeout); 53 | resolve(buffer); 54 | }); 55 | }); 56 | } 57 | 58 | module.exports = captureScreenshot; 59 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/combine-images.js: -------------------------------------------------------------------------------- 1 | const gm = require('gm').subClass({ imageMagick: true }); 2 | 3 | function combineImages(fileArray) { 4 | console.log(`Combining images....`); 5 | return new Promise((resolve, reject) => { 6 | gm(fileArray[0]) 7 | .append(fileArray[1], false) 8 | .shave(1, 1) 9 | .toBuffer('jpg', (error, newBuffer) => { 10 | if (error) { 11 | console.error('Error converting to JPEG....', error); 12 | reject(error); 13 | return; 14 | } 15 | 16 | resolve(newBuffer); 17 | }); 18 | }); 19 | } 20 | 21 | module.exports = combineImages; 22 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/crop-screenshot.js: -------------------------------------------------------------------------------- 1 | const gm = require('gm').subClass({ imageMagick: true }); 2 | 3 | function cropScreenshot(buffer, response) { 4 | const rect = response.result.value; 5 | 6 | console.log('Cropping screenshot....', rect); 7 | return new Promise((resolve, reject) => { 8 | gm(buffer) 9 | .crop(rect.width, rect.height, rect.left, rect.top) 10 | .toBuffer('png', (error, newBuffer) => { 11 | if (error) { 12 | console.error('Error cropping screenshot....', error); 13 | reject(error); 14 | return; 15 | } 16 | 17 | resolve(newBuffer); 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = cropScreenshot; 23 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/get-element-rect.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const inject = (selector) => { 4 | const el = document.querySelector(selector); 5 | const rect = el ? el.getBoundingClientRect() : {}; 6 | 7 | return { 8 | bottom: rect.bottom, 9 | height: rect.height, 10 | left: rect.left, 11 | right: rect.right, 12 | top: rect.top, 13 | width: rect.width, 14 | }; 15 | }; 16 | 17 | function getElementRect(selector) { 18 | console.log(`Getting bounding rect for ${selector}....`); 19 | return `(${inject.toString()})('${selector}')`; 20 | } 21 | 22 | module.exports = getElementRect; 23 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/inject-stylesheet.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const inject = (cssString) => { 7 | const addTo = (doc) => { 8 | const style = doc.createElement('style'); 9 | style.type = 'text/css'; 10 | style.innerHTML = cssString; 11 | 12 | doc.querySelector('head').appendChild(style); 13 | }; 14 | 15 | addTo(document); 16 | 17 | const cardFrame = document.querySelector('.card2 iframe'); 18 | if (cardFrame) { 19 | console.log('Injecting in Twitter card frame....'); 20 | addTo(cardFrame.contentDocument); 21 | } 22 | }; 23 | 24 | function injectStylesheet(stylesheet) { 25 | console.log(`Injecting stylesheet ${stylesheet}....`); 26 | 27 | const data = fs.readFileSync(path.resolve(stylesheet)); 28 | const cssString = data.toString().replace(/\n/g, ''); 29 | const injectString = inject.toString().replace(/\n/g, ''); 30 | 31 | return `(${injectString})('${cssString}')`; 32 | } 33 | 34 | module.exports = injectStylesheet; 35 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/launch-chrome.js: -------------------------------------------------------------------------------- 1 | const cdp = require('chrome-remote-interface'); 2 | const lambdaChrome = require('@serverless-chrome/lambda'); 3 | 4 | const defaults = { 5 | debug: true, 6 | flags: [], 7 | port: 9222, 8 | timeout: 1000 * 10, 9 | tmpDir: '/tmp', 10 | }; 11 | 12 | function getCdpInstance(options) { 13 | return new Promise((resolve, reject) => { 14 | if (options.debug) { 15 | cdp.Version((err, info) => { 16 | console.log('CDP version info:', info); 17 | }); 18 | } 19 | 20 | cdp({ port: options.port }, resolve).on('error', reject); 21 | }); 22 | } 23 | 24 | function getChromeFlags(options) { 25 | return [ 26 | '--headless', 27 | '--disable-gpu', 28 | '--no-sandbox', 29 | '--hide-scrollbars', 30 | '--enable-logging', 31 | '--log-level=0', 32 | '--v=99', 33 | '--single-process', 34 | `--remote-debugging-port=${options.port}`, 35 | `--user-data-dir=${options.tmpDir}/user-data`, 36 | `--data-path=${options.tmpDir}/data-path`, 37 | `--homedir=${options.tmpDir}`, 38 | `--disk-cache-dir=${options.tmpDir}/cache-dir`, 39 | ].concat(options.flags); 40 | } 41 | 42 | function getOptions(userOptions) { 43 | const options = Object.assign({}, defaults, userOptions); 44 | options.flags = getChromeFlags(options); 45 | return options; 46 | } 47 | 48 | function launchChrome(userOptions = {}) { 49 | const options = getOptions(userOptions); 50 | return lambdaChrome().then(() => getCdpInstance(options)); 51 | } 52 | 53 | module.exports = launchChrome; 54 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/prepare-chrome.js: -------------------------------------------------------------------------------- 1 | // Set up viewport resolution, etc. 2 | const deviceMetrics = { 3 | width: 1200, 4 | height: 1200, 5 | deviceScaleFactor: 2, 6 | mobile: false, 7 | fitWindow: true, 8 | }; 9 | 10 | function prepareChrome(client) { 11 | return new Promise((resolve, reject) => { 12 | const { Emulation, Page, Runtime } = client; 13 | 14 | const preparation = [ 15 | Page.enable(), 16 | Runtime.enable(), 17 | Emulation.setDeviceMetricsOverride(deviceMetrics), 18 | Emulation.setVisibleSize({ width: deviceMetrics.width, height: deviceMetrics.height }), 19 | ].reduce((p, fn) => p.then(fn), Promise.resolve()); 20 | 21 | preparation.then(() => { 22 | resolve(client); 23 | }) 24 | }); 25 | } 26 | 27 | module.exports = prepareChrome; 28 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/save-to-s3.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const S3 = require('aws-sdk/clients/s3'); 3 | 4 | const config = require('../config'); 5 | 6 | function saveToS3(buffer) { 7 | // Generate hash suffix for filename. 8 | const hash = crypto.createHmac('sha512', '41ecde00bfbe5b8ca3b82601b749bdf3'); 9 | hash.update(`${Date.now()}`); 10 | 11 | const s3Params = { 12 | ACL: 'public-read', 13 | Body: buffer, 14 | Bucket: config.s3.bucket, 15 | ContentType: 'image/jpeg', 16 | Key: `compositebot/${hash.digest('hex').substr(0, 16)}.jpg`, 17 | }; 18 | 19 | console.log('Saving screenshot to S3....'); 20 | return saveImage(s3Params); 21 | } 22 | 23 | function saveImage(s3Params) { 24 | const s3 = new S3(); 25 | 26 | return new Promise((resolve, reject) => { 27 | s3.putObject(s3Params, (error, data) => { 28 | if (error) { 29 | reject(error); 30 | return; 31 | } 32 | 33 | resolve({ 34 | etag: data.ETag, 35 | key: s3Params.Key, 36 | }); 37 | }); 38 | }); 39 | } 40 | 41 | module.exports = saveToS3; 42 | -------------------------------------------------------------------------------- /utility/screenshot-compositor/src/utils.js: -------------------------------------------------------------------------------- 1 | function delay(timeout) { 2 | return value => new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve(value); 5 | }, timeout); 6 | }); 7 | } 8 | 9 | module.exports = { 10 | delay, 11 | }; 12 | --------------------------------------------------------------------------------