├── .gitignore ├── MIT-LICENSE ├── Makefile ├── Procfile ├── README.md ├── app.js ├── app.json ├── common ├── db.js ├── eval.js └── mailer.js ├── config.js ├── controllers ├── events.js ├── hooks.js └── sessions.js ├── models └── hook.js ├── package.json ├── routes.js ├── test └── fixtures │ └── event.json └── views ├── hooks ├── list.ejs └── show.ejs └── layout.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.env 10 | 11 | pids 12 | logs 13 | results 14 | 15 | npm-debug.log 16 | node_modules 17 | 18 | .bundle.js 19 | coverage 20 | *.db 21 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Alexander MacCaw (alex@alexmaccaw.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS = $(shell ls -S `find test -type f -name "*.test.js" -print`) 2 | REPORTER = spec 3 | TIMEOUT = 3000 4 | MOCHA_OPTS = 5 | REGISTRY = "--registry=http://registry.npm.taobao.org" 6 | 7 | install: 8 | @npm install $(REGISTRY) 9 | 10 | test: 11 | @NODE_ENV=test ./node_modules/.bin/mocha \ 12 | --harmony \ 13 | --reporter $(REPORTER) \ 14 | --timeout $(TIMEOUT) \ 15 | $(MOCHA_OPTS) \ 16 | $(TESTS) 17 | 18 | test-cov: 19 | @NODE_ENV=test node --harmony \ 20 | node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha \ 21 | -- -u exports \ 22 | --reporter $(REPORTER) \ 23 | --timeout $(TIMEOUT) \ 24 | $(MOCHA_OPTS) \ 25 | $(TESTS) 26 | 27 | test-travis: 28 | @NODE_ENV=test node --harmony \ 29 | node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha \ 30 | --report lcovonly \ 31 | -- -u exports \ 32 | --reporter $(REPORTER) \ 33 | --timeout $(TIMEOUT) \ 34 | $(MOCHA_OPTS) \ 35 | $(TESTS) 36 | 37 | .PHONY: test 38 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Segment Hooks 2 | 3 | Segment Hooks lets you execute arbitrary JavaScript when specific [Segment](http://segment.com) events are triggered. You could, for example, post a message in Slack when a user first signs up, or send an email to yourself when someone goes over their monthly quota. 4 | 5 | ![Screenshot](https://cloud.githubusercontent.com/assets/2142/17839073/279c355c-6792-11e6-879d-39a12a914302.png) 6 | 7 | 8 | ## Getting started 9 | 10 | The simplest way of getting up and running is via the Heroku button below. 11 | 12 | [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 13 | 14 | ### Configuring Segment 15 | 16 | Once you have a Heroku endpoint, you'll need to set up a [Segment](http://segment.com) webhook. You can add a custom webhook endpoint under your Segment apps integrations. 17 | 18 | ![Segment](https://cloud.githubusercontent.com/assets/2142/17839081/4a88c83c-6792-11e6-8fb4-875c1022a89b.png) 19 | 20 | Configure the endpoint to be `https://your-app.herokuapp.com/events` - notice the `/events` trailing path. 21 | 22 | That's it - you're all setup! 23 | 24 | ## Authentication 25 | 26 | Clearly this app is best concealed behind a authentication layer. Segment Hooks comes with out the box support for Google Apps auth (and it's fairly straightorward to add alternatives). You'll need to set the following env vars: 27 | 28 | GOOGLE_CALLBACK: your-app.herokuapp.com 29 | GOOGLE_DOMAIN: your-google-apps-domain.com 30 | GOOGLE_KEY: your-google-key 31 | GOOGLE_SECRET: your-google-secret 32 | 33 | You generate the values for `GOOGLE_KEY` and `GOOGLE_SECRET` in [Google's API console](https://console.developers.google.com). 34 | 35 | ## Sending HTTP requests 36 | 37 | Segment Hooks bundles the [requests lib](https://github.com/request/request), so sending HTTP requests is very straightforard. For example, a Hook's JavaScript might look like this: 38 | 39 | ```javascript 40 | request.post({ 41 | url: "https://hooks.slack.com/services/your-hook-id", 42 | json: {text: event.properties.message} 43 | }); 44 | ``` 45 | 46 | ## Sending email 47 | 48 | To send email, you'll need to add the `mailgun` Heroku addon. You'll also need to confirm your email address with Mailgun, and setup a custom domain. 49 | 50 | Once that's done, the following JavaScript will send an email. 51 | 52 | ```javascript 53 | sendMail({ 54 | to: 'alex@clearbit.com', 55 | subject: 'Customer signup', 56 | text: (event.properties.email + ' just signed up!') 57 | }); 58 | ``` 59 | 60 | ## Other libraries 61 | 62 | To use other Node libraries, simply add them to the `package.json` dependencies and deploy to Heroku. A Hook can access all of the usual node context. 63 | 64 | ## Testing 65 | 66 | Run: 67 | 68 | ```shell 69 | cat test/fixtures/event.json | \ 70 | curl -X POST \ 71 | -H "Content-Type: application/json" \ 72 | -d @- \ 73 | http://localhost:7001/events 74 | ``` 75 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | require('dotenv').config({silent: true}); 8 | 9 | var middlewares = require('koa-middlewares'), 10 | routes = require('./routes'), 11 | config = require('./config'), 12 | path = require('path'), 13 | http = require('http'), 14 | koa = require('koa'), 15 | render = require('koa-ejs'), 16 | session = require('koa-session'), 17 | override = require('koamethodoverride'), 18 | Grant = require('grant-koa'), 19 | mount = require('koa-mount'); 20 | 21 | var app = koa(); 22 | 23 | /** 24 | * ignore favicon 25 | */ 26 | app.use(middlewares.favicon()); 27 | 28 | /** 29 | * response time header 30 | */ 31 | app.use(middlewares.rt()); 32 | 33 | /** 34 | * static file server 35 | */ 36 | app.use(middlewares.staticCache(path.join(__dirname, 'public'), { 37 | buffer: !config.debug, 38 | maxAge: config.debug ? 0 : 60 * 60 * 24 * 7 39 | })); 40 | 41 | app.use(middlewares.bodyParser()); 42 | app.use(override()); 43 | 44 | if (config.debug && process.env.NODE_ENV !== 'test') { 45 | app.use(middlewares.logger()); 46 | } 47 | 48 | /** 49 | * session 50 | */ 51 | app.keys = ['grant']; 52 | app.use(session(app)); 53 | 54 | 55 | /** 56 | * auth barrier 57 | */ 58 | 59 | if (config.grant.enabled) { 60 | var grant = new Grant(config.grant); 61 | app.use(mount(grant)); 62 | 63 | app.use(function*(next){ 64 | if ( !/^\/hooks/.test(this.request.url) ) return yield next; 65 | if ( this.session.accessToken ) return yield next; 66 | 67 | this.redirect('/connect/google'); 68 | }); 69 | } 70 | 71 | /** 72 | * router 73 | */ 74 | app.use(middlewares.router(app)); 75 | routes(app); 76 | 77 | render(app, { 78 | root: path.join(__dirname, 'views'), 79 | layout: 'layout', 80 | viewExt: 'ejs', 81 | cache: false, 82 | debug: true 83 | }); 84 | 85 | app = module.exports = http.createServer(app.callback()); 86 | 87 | if (!module.parent) { 88 | app.listen(config.port); 89 | console.log('$ open http://127.0.0.1:' + config.port); 90 | } 91 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Segment Hooks", 3 | "description": "Execute arbitary JavaScript from Segment events", 4 | "repository": "https://github.com/maccman/segment-hooks", 5 | "logo": "https://cloud.githubusercontent.com/assets/2142/17839119/8f7f1a76-6793-11e6-97ce-8fc17cd8b6d3.png", 6 | "keywords": ["node", "segment"], 7 | "addons": [ 8 | "heroku-postgresql" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /common/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Sequelize = require('sequelize') 4 | 5 | module.exports = new Sequelize(process.env.DATABASE_URL); 6 | -------------------------------------------------------------------------------- /common/eval.js: -------------------------------------------------------------------------------- 1 | let request = require('co-request'); 2 | let sendMail = require('./mailer').sendMail; 3 | 4 | module.exports = function* (source, event) { 5 | return eval(source); 6 | }; 7 | -------------------------------------------------------------------------------- /common/mailer.js: -------------------------------------------------------------------------------- 1 | var nodemailer = require('nodemailer'); 2 | var mailer; 3 | 4 | if (process.env.MAIL_URL) { 5 | mailer = nodemailer.createTransport(process.env.MAIL_URL); 6 | } else if (process.env.MAILGUN_SMTP_SERVER) { 7 | mailer = nodemailer.createTransport({ 8 | host: process.env.MAILGUN_SMTP_SERVER, 9 | port: process.env.MAILGUN_SMTP_PORT, 10 | auth: { 11 | user: process.env.MAILGUN_SMTP_LOGIN, 12 | pass: process.env.MAILGUN_SMTP_PASSWORD 13 | } 14 | }); 15 | } 16 | 17 | if (mailer) { 18 | exports.sendMail = mailer.sendMail.bind(mailer); 19 | } else { 20 | exports.sendMail = null; 21 | } 22 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var version = require('./package.json').version; 8 | var path = require('path'); 9 | 10 | var config = { 11 | version: version, 12 | debug: process.env.NODE_ENV !== 'production', 13 | port: process.env.PORT || 7001, 14 | 15 | grant: { 16 | enabled: !!process.env.GOOGLE_KEY, 17 | server: { 18 | protocol: "https", 19 | host: process.env.GOOGLE_CALLBACK 20 | }, 21 | google: { 22 | key: process.env.GOOGLE_KEY, 23 | secret: process.env.GOOGLE_SECRET, 24 | callback: "/sessions/create", 25 | custom_params: { 26 | hd: process.env.GOOGLE_DOMAIN, 27 | access_type: "online" 28 | }, 29 | scope: [ 30 | "email", 31 | "profile" 32 | ] 33 | } 34 | } 35 | }; 36 | 37 | module.exports = config; 38 | -------------------------------------------------------------------------------- /controllers/events.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hook = require('../models/hook'); 4 | 5 | exports.process = function* () { 6 | var event = this.request.body; 7 | 8 | console.log('Processing event', event); 9 | 10 | var query = {type: event.type}; 11 | 12 | if (event.event) { 13 | Object.assign(query, {event: event.event}); 14 | } 15 | 16 | var hooks = yield Hook.findAll({where: query}); 17 | 18 | console.log('Found hooks count', hooks.length); 19 | 20 | this.status = 200; 21 | 22 | for (var i = hooks.length - 1; i >= 0; i--) { 23 | try { 24 | yield hooks[i].process(event); 25 | } catch (err) { 26 | this.body = err + ''; 27 | this.status = 500; 28 | break; 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /controllers/hooks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | let Hook = require('../models/hook'); 8 | 9 | exports.list = function* () { 10 | let hooks = yield Hook.findAll(); 11 | 12 | yield this.render('hooks/list', {hooks: hooks}); 13 | }; 14 | 15 | exports.add = function* () { 16 | let hook = yield Hook.create(this.request.body); 17 | 18 | this.redirect('/hooks'); 19 | }; 20 | 21 | exports.test = function* () { 22 | let hook = yield Hook.findById(this.params.id); 23 | 24 | console.log('Executing:', hook.script); 25 | 26 | try { 27 | let result = yield hook.test(); 28 | console.log(result) 29 | this.body = result + ''; 30 | } catch (err) { 31 | console.error(err); 32 | this.body = err + ''; 33 | } 34 | 35 | this.status = 200; 36 | }; 37 | 38 | exports.update = function* () { 39 | let hook = yield Hook.findById(this.params.id); 40 | 41 | yield hook.update(this.request.body); 42 | 43 | this.redirect('/hooks'); 44 | }; 45 | 46 | exports.destroy = function* () { 47 | let hook = yield Hook.findById(this.params.id); 48 | yield hook.destroy() 49 | this.redirect('/hooks'); 50 | }; 51 | -------------------------------------------------------------------------------- /controllers/sessions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.create = function* () { 4 | this.session.accessToken = this.query.access_token; 5 | this.redirect('/'); 6 | }; 7 | -------------------------------------------------------------------------------- /models/hook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('../common/db'); 4 | const Sequelize = require('sequelize'); 5 | const eventEval = require('../common/eval'); 6 | const testEvent = require('../test/fixtures/event'); 7 | 8 | let Hook = db.define('hooks', { 9 | name: Sequelize.STRING, 10 | type: Sequelize.STRING, 11 | event: Sequelize.STRING, 12 | script: Sequelize.STRING, 13 | }, { 14 | underscore: true, 15 | underscoredAll: true, 16 | updatedAt: 'updated_at', 17 | createdAt: 'created_at', 18 | 19 | instanceMethods: { 20 | process: function* (event, next) { 21 | return yield eventEval(this.script, event); 22 | }, 23 | 24 | test: function* (next) { 25 | let event = Object.assign({}, 26 | testEvent, 27 | {type: this.event} 28 | ); 29 | 30 | return yield this.process(event); 31 | } 32 | } 33 | }); 34 | 35 | Hook.sync(); 36 | 37 | module.exports = Hook; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "segment-hook", 3 | "version": "0.0.1", 4 | "description": "Process segment events.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "make test", 8 | "start": "node --harmony app.js" 9 | }, 10 | "dependencies": { 11 | "co-request": "^1.0.0", 12 | "dotenv": "^2.0.0", 13 | "grant-koa": "^3.6.3", 14 | "koa": "~0.19.1", 15 | "koa-ejs": "^3.0.0", 16 | "koa-middlewares": "^2.1.0", 17 | "koa-mount": "^1.3.0", 18 | "koamethodoverride": "^1.0.1", 19 | "nodemailer": "^2.5.0", 20 | "pg": "^6.1.0", 21 | "request": "^2.74.0", 22 | "sequelize": "^3.24.1", 23 | "sqlite3": "^3.1.4" 24 | }, 25 | "devDependencies": { 26 | "should": "3.1.3" 27 | }, 28 | "homepage": "https://github.com/maccman/segment-hook", 29 | "repository": { 30 | "type": "git", 31 | "url": "git://github.com/maccman/segment-hook.git" 32 | }, 33 | "keywords": [ 34 | "todo" 35 | ], 36 | "engines": { 37 | "node": "~6.4.0" 38 | }, 39 | "author": "Alex MacCaw ", 40 | "license": "MIT" 41 | } 42 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var hooks = require('./controllers/hooks'); 8 | var events = require('./controllers/events'); 9 | var sessions = require('./controllers/sessions'); 10 | 11 | module.exports = function routes(app) { 12 | app.get('/', function*(){ 13 | this.redirect('/hooks'); 14 | }); 15 | app.get('/hooks', hooks.list); 16 | app.post('/hooks', hooks.add); 17 | app.post('/hooks/:id/test', hooks.test); 18 | app.put('/hooks/:id', hooks.update); 19 | app.del('/hooks/:id', hooks.destroy); 20 | app.post('/events', events.process); 21 | app.get('/sessions/create', sessions.create); 22 | }; 23 | -------------------------------------------------------------------------------- /test/fixtures/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "version" : 1, 3 | "type" : "track", 4 | "event" : "dummy", 5 | "userId" : "019mr8mf4r", 6 | "traits" : { 7 | "email" : "achilles@segment.com", 8 | "name" : "Achilles", 9 | "subscriptionPlan" : "Premium", 10 | "friendCount" : 29 11 | }, 12 | "properties" : { 13 | "email" : "achilles@segment.com", 14 | "name" : "Achilles", 15 | "subscriptionPlan" : "Premium", 16 | "friendCount" : 29 17 | }, 18 | "timestamp" : "2012-12-02T00:30:08.276Z" 19 | } 20 | -------------------------------------------------------------------------------- /views/hooks/list.ejs: -------------------------------------------------------------------------------- 1 |

Segment Hooks

2 | 3 | 116 | -------------------------------------------------------------------------------- /views/hooks/show.ejs: -------------------------------------------------------------------------------- 1 |

Hook: <%= hook.id %>

2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 |
10 | -------------------------------------------------------------------------------- /views/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | Segment Hooks 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 37 | 38 | 39 |
40 | <%- body %> 41 |
42 | 43 | 44 | --------------------------------------------------------------------------------