├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── bearer-token └── index.js ├── cli.js ├── client └── index.js ├── demo.gif ├── env.twitter.template ├── errors └── index.js ├── examples ├── listen.js └── standalone-server.js ├── index.js ├── oauth └── index.js ├── package.json ├── screenshot.png └── test ├── autohook-error.js ├── autohook-success.js ├── bearer-token.js ├── client.js ├── errors.js ├── oauth.js └── signature.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2020": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 11 10 | }, 11 | "rules": { 12 | "accessor-pairs": "error", 13 | "array-bracket-newline": "off", 14 | "array-bracket-spacing": [ 15 | "error", 16 | "never" 17 | ], 18 | "array-callback-return": "off", 19 | "array-element-newline": "off", 20 | "arrow-body-style": "error", 21 | "arrow-parens": "error", 22 | "arrow-spacing": [ 23 | "error", 24 | { 25 | "after": true, 26 | "before": true 27 | } 28 | ], 29 | "block-scoped-var": "error", 30 | "block-spacing": "error", 31 | "brace-style": [ 32 | "error", 33 | "1tbs" 34 | ], 35 | "callback-return": "error", 36 | "camelcase": "off", 37 | "capitalized-comments": "error", 38 | "class-methods-use-this": "error", 39 | "comma-dangle": "off", 40 | "comma-spacing": [ 41 | "error", 42 | { 43 | "after": true, 44 | "before": false 45 | } 46 | ], 47 | "comma-style": [ 48 | "error", 49 | "last" 50 | ], 51 | "complexity": "error", 52 | "computed-property-spacing": [ 53 | "error", 54 | "never" 55 | ], 56 | "consistent-return": "error", 57 | "consistent-this": "error", 58 | "curly": "error", 59 | "default-case": "error", 60 | "default-case-last": "error", 61 | "default-param-last": "error", 62 | "dot-location": [ 63 | "error", 64 | "property" 65 | ], 66 | "dot-notation": "error", 67 | "eol-last": "off", 68 | "eqeqeq": "error", 69 | "func-call-spacing": "error", 70 | "func-name-matching": "error", 71 | "func-names": "error", 72 | "func-style": [ 73 | "error", 74 | "expression" 75 | ], 76 | "function-call-argument-newline": [ 77 | "error", 78 | "consistent" 79 | ], 80 | "function-paren-newline": "off", 81 | "generator-star-spacing": "error", 82 | "global-require": "error", 83 | "grouped-accessor-pairs": "error", 84 | "guard-for-in": "off", 85 | "handle-callback-err": "error", 86 | "id-blacklist": "error", 87 | "id-denylist": "error", 88 | "id-length": "off", 89 | "id-match": "error", 90 | "implicit-arrow-linebreak": [ 91 | "error", 92 | "beside" 93 | ], 94 | "indent": "off", 95 | "indent-legacy": "off", 96 | "init-declarations": "error", 97 | "jsx-quotes": "error", 98 | "key-spacing": "error", 99 | "keyword-spacing": "error", 100 | "line-comment-position": "error", 101 | "linebreak-style": [ 102 | "error", 103 | "unix" 104 | ], 105 | "lines-around-comment": "error", 106 | "lines-around-directive": "error", 107 | "lines-between-class-members": [ 108 | "error", 109 | "always" 110 | ], 111 | "max-classes-per-file": "error", 112 | "max-depth": "error", 113 | "max-len": "off", 114 | "max-lines": "off", 115 | "max-lines-per-function": "error", 116 | "max-nested-callbacks": "error", 117 | "max-params": "off", 118 | "max-statements": "off", 119 | "max-statements-per-line": "error", 120 | "multiline-comment-style": "error", 121 | "multiline-ternary": "error", 122 | "new-cap": "error", 123 | "new-parens": "error", 124 | "newline-after-var": "off", 125 | "newline-before-return": "off", 126 | "newline-per-chained-call": "off", 127 | "no-alert": "error", 128 | "no-array-constructor": "error", 129 | "no-await-in-loop": "off", 130 | "no-bitwise": "error", 131 | "no-buffer-constructor": "error", 132 | "no-caller": "error", 133 | "no-catch-shadow": "error", 134 | "no-confusing-arrow": "error", 135 | "no-console": "off", 136 | "no-constructor-return": "error", 137 | "no-continue": "error", 138 | "no-div-regex": "error", 139 | "no-duplicate-imports": "error", 140 | "no-else-return": "error", 141 | "no-empty-function": "error", 142 | "no-eq-null": "error", 143 | "no-eval": "error", 144 | "no-extend-native": "error", 145 | "no-extra-bind": "error", 146 | "no-extra-label": "error", 147 | "no-extra-parens": "error", 148 | "no-floating-decimal": "error", 149 | "no-implicit-globals": "off", 150 | "no-implied-eval": "error", 151 | "no-inline-comments": "error", 152 | "no-invalid-this": "error", 153 | "no-iterator": "error", 154 | "no-label-var": "error", 155 | "no-labels": "error", 156 | "no-lone-blocks": "error", 157 | "no-lonely-if": "error", 158 | "no-loop-func": "error", 159 | "no-loss-of-precision": "error", 160 | "no-magic-numbers": "off", 161 | "no-mixed-operators": "error", 162 | "no-mixed-requires": "error", 163 | "no-multi-assign": "error", 164 | "no-multi-spaces": "error", 165 | "no-multi-str": "error", 166 | "no-multiple-empty-lines": "error", 167 | "no-native-reassign": "error", 168 | "no-negated-condition": "error", 169 | "no-negated-in-lhs": "error", 170 | "no-nested-ternary": "error", 171 | "no-new": "error", 172 | "no-new-func": "error", 173 | "no-new-object": "error", 174 | "no-new-require": "error", 175 | "no-new-wrappers": "error", 176 | "no-octal-escape": "error", 177 | "no-param-reassign": "off", 178 | "no-path-concat": "error", 179 | "no-plusplus": "error", 180 | "no-process-env": "off", 181 | "no-process-exit": "off", 182 | "no-promise-executor-return": "error", 183 | "no-proto": "error", 184 | "no-restricted-exports": "error", 185 | "no-restricted-globals": "error", 186 | "no-restricted-imports": "error", 187 | "no-restricted-modules": "error", 188 | "no-restricted-properties": "error", 189 | "no-restricted-syntax": "error", 190 | "no-return-assign": "error", 191 | "no-return-await": "error", 192 | "no-script-url": "error", 193 | "no-self-compare": "error", 194 | "no-sequences": "error", 195 | "no-shadow": "off", 196 | "no-spaced-func": "error", 197 | "no-sync": "error", 198 | "no-tabs": "error", 199 | "no-template-curly-in-string": "error", 200 | "no-ternary": "error", 201 | "no-throw-literal": "error", 202 | "no-trailing-spaces": "off", 203 | "no-undef-init": "error", 204 | "no-undefined": "off", 205 | "no-underscore-dangle": "off", 206 | "no-unmodified-loop-condition": "error", 207 | "no-unneeded-ternary": "error", 208 | "no-unreachable-loop": "error", 209 | "no-unused-expressions": "error", 210 | "no-use-before-define": "error", 211 | "no-useless-backreference": "error", 212 | "no-useless-call": "error", 213 | "no-useless-computed-key": "error", 214 | "no-useless-concat": "error", 215 | "no-useless-constructor": "error", 216 | "no-useless-rename": "error", 217 | "no-useless-return": "error", 218 | "no-var": "error", 219 | "no-void": "error", 220 | "no-warning-comments": "error", 221 | "no-whitespace-before-property": "error", 222 | "nonblock-statement-body-position": "error", 223 | "object-curly-newline": "error", 224 | "object-curly-spacing": "off", 225 | "object-shorthand": "error", 226 | "one-var": "off", 227 | "one-var-declaration-per-line": "error", 228 | "operator-assignment": [ 229 | "error", 230 | "always" 231 | ], 232 | "operator-linebreak": "error", 233 | "padded-blocks": "off", 234 | "padding-line-between-statements": "error", 235 | "prefer-arrow-callback": "error", 236 | "prefer-const": "error", 237 | "prefer-destructuring": "off", 238 | "prefer-exponentiation-operator": "error", 239 | "prefer-named-capture-group": "error", 240 | "prefer-numeric-literals": "error", 241 | "prefer-object-spread": "error", 242 | "prefer-promise-reject-errors": "error", 243 | "prefer-reflect": "error", 244 | "prefer-regex-literals": "error", 245 | "prefer-rest-params": "error", 246 | "prefer-spread": "error", 247 | "prefer-template": "off", 248 | "quote-props": "off", 249 | "quotes": ["error", "single", "avoid-escape"], 250 | "radix": "error", 251 | "require-atomic-updates": "off", 252 | "require-await": "off", 253 | "require-jsdoc": "error", 254 | "require-unicode-regexp": "error", 255 | "rest-spread-spacing": "error", 256 | "semi": "error", 257 | "semi-spacing": "error", 258 | "semi-style": [ 259 | "error", 260 | "last" 261 | ], 262 | "sort-imports": "error", 263 | "sort-keys": "off", 264 | "sort-vars": "error", 265 | "space-before-blocks": "error", 266 | "space-before-function-paren": "off", 267 | "space-in-parens": [ 268 | "error", 269 | "never" 270 | ], 271 | "space-infix-ops": "error", 272 | "space-unary-ops": "error", 273 | "spaced-comment": "error", 274 | "strict": [ 275 | "error", 276 | "never" 277 | ], 278 | "switch-colon-spacing": "error", 279 | "symbol-description": "error", 280 | "template-curly-spacing": [ 281 | "error", 282 | "never" 283 | ], 284 | "template-tag-spacing": "error", 285 | "unicode-bom": [ 286 | "error", 287 | "never" 288 | ], 289 | "valid-jsdoc": "error", 290 | "vars-on-top": "error", 291 | "wrap-iife": "error", 292 | "wrap-regex": "error", 293 | "yield-star-spacing": "error", 294 | "yoda": [ 295 | "error", 296 | "never" 297 | ] 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | One line summary of the issue here. 2 | 3 | ### Expected behavior 4 | 5 | As concisely as possible, describe the expected behavior. 6 | 7 | ### Actual behavior 8 | 9 | As concisely as possible, describe the observed behavior. 10 | 11 | ### Steps to reproduce the behavior 12 | 13 | Please list all relevant steps to reproduce the observed behavior. 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Problem 2 | 3 | Explain the context and why you're making that change. What is the 4 | problem you're trying to solve? In some cases there is not a problem 5 | and this can be thought of being the motivation for your change. 6 | 7 | ### Solution 8 | 9 | Describe the modifications you've done. 10 | 11 | ### Result 12 | 13 | What will change as a result of your pull request? Note that sometimes 14 | this section is unnecessary because it is self-explanatory based on 15 | the solution. 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # OSX file 61 | .DS_Store 62 | 63 | # Project files 64 | .env 65 | config.json 66 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo.gif 2 | screenshot.png -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | We feel that a welcoming community is important and we ask that you follow Twitter's 2 | [Open Source Code of Conduct](https://github.com/twitter/code-of-conduct/blob/master/code-of-conduct.md) 3 | in all interactions with the community. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Twitter, Inc. 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autohook 🎣 2 | 3 | Autohook configures and manages [Twitter webhooks](https://developer.twitter.com/en/docs/accounts-and-users/subscribe-account-activity/guides/managing-webhooks-and-subscriptions) for you. Zero configuration. Just run and go! 4 | 5 | ![Demo](https://github.com/twitterdev/autohook/raw/master/demo.gif) 6 | 7 | * 🚀 Spawns a server for you 8 | * ⚙️ Registers a webhook (it removes existing webhooks if you want, and you can add more than one webhook if your Premium subscription supports it) 9 | * ✅ Performs the CRC validation when needed 10 | * 📝 Subscribes to your current user's context (you can always subscribe more users if you need) 11 | * 🎧 Exposes a listener so you can pick up Account Activity events and process the ones you care about 12 | 13 | ## Usage 14 | 15 | You can use Autohook as a module or as a command-line tool. 16 | 17 | ### Node.js module 18 | 19 | ```js 20 | const { Autohook } = require('twitter-autohook'); 21 | 22 | (async ƛ => { 23 | const webhook = new Autohook(); 24 | 25 | // Removes existing webhooks 26 | await webhook.removeWebhooks(); 27 | 28 | // Listens to incoming activity 29 | webhook.on('event', event => console.log('Something happened:', event)); 30 | 31 | // Starts a server and adds a new webhook 32 | await webhook.start(); 33 | 34 | // Subscribes to a user's activity 35 | await webhook.subscribe({oauth_token, oauth_token_secret}); 36 | })(); 37 | ``` 38 | 39 | ### Command line 40 | 41 | Starting Autohook from the command line is useful when you need to test your connection and subscriptions. 42 | 43 | When started from the command line, Autohook simply provisions a webhook, subscribes your user (unless you specify `--do-not-subscribe-me`), and echoes incoming events to `stdout`. 44 | 45 | ```bash 46 | # Starts a server, removes any existing webhook, adds a new webhook, and subscribes to the authenticating user's activity. 47 | $ autohook -rs 48 | 49 | # All the options 50 | $ autohook --help 51 | ``` 52 | 53 | ## OAuth 54 | 55 | Autohook works only when you pass your OAuth credentials. You won't have to figure out OAuth by yourself – Autohook will work that out for you. 56 | 57 | You can pass your OAuth credentials in a bunch of ways. 58 | 59 | ## Dotenv (~/.env.twitter) 60 | 61 | Create a file named `~/.env.twitter` (sits in your home dir) with the following variables: 62 | 63 | ```bash 64 | TWITTER_CONSUMER_KEY= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ API key 65 | TWITTER_CONSUMER_SECRET= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ API secret key 66 | TWITTER_ACCESS_TOKEN= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ Access token 67 | TWITTER_ACCESS_TOKEN_SECRET= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ Access token secret 68 | TWITTER_WEBHOOK_ENV= # https://developer.twitter.com/en/account/environments ➡️ One of 'Dev environment label' or 'Prod environment label' 69 | NGROK_AUTH_TOKEN= # https://ngrok.com/ - (optional) Create a free account to get your auth token for stable tunnels 70 | ``` 71 | 72 | Autohook will pick up these details automatically, so you won't have to specify anything in code or via CLI. 73 | 74 | ## Env variables 75 | 76 | Useful when you're deploying to remote servers, and can be used in conjunction with your dotenv file. 77 | 78 | ```bash 79 | 80 | # To your current environment 81 | export TWITTER_CONSUMER_KEY= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ API key 82 | export TWITTER_CONSUMER_SECRET= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ API secret key 83 | export TWITTER_ACCESS_TOKEN= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ Access token 84 | export TWITTER_ACCESS_TOKEN_SECRET= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ Access token secret 85 | export TWITTER_WEBHOOK_ENV= # https://developer.twitter.com/en/account/environments ➡️ One of 'Dev environment label' or 'Prod environment label' 86 | export NGROK_AUTH_TOKEN= # https://ngrok.com/ - (optional) Create a free account to get your auth token for stable tunnels 87 | 88 | # To other services, e.g. Heroku 89 | heroku config:set TWITTER_CONSUMER_KEY=value TWITTER_CONSUMER_SECRET=value TWITTER_ACCESS_TOKEN=value TWITTER_ACCESS_TOKEN_SECRET=value TWITTER_WEBHOOK_ENV=value NGROK_AUTH_TOKEN=value 90 | ``` 91 | ## Directly 92 | 93 | Not recommended, because you should always [secure your credentials](https://developer.twitter.com/en/docs/basics/authentication/guides/securing-keys-and-tokens.html). 94 | 95 | ### Node.js 96 | 97 | ```js 98 | new Autohook({ 99 | token: 'value', 100 | token_secret: 'value', 101 | consumer_key: 'value', 102 | consumer_secret: 'value', 103 | ngrok_secret: 'value', // optional 104 | env: 'env', 105 | port: 1337 106 | }); 107 | ``` 108 | 109 | ### CLI 110 | 111 | ```bash 112 | $ autohook \ 113 | --token $TWITTER_ACCESS_TOKEN \ 114 | --secret $TWITTER_ACCESS_TOKEN_SECRET \ 115 | --consumer-key $TWITTER_CONSUMER_KEY \ 116 | --consumer-secret $TWITTER_CONSUMER_SECRET \ 117 | --env $TWITTER_WEBHOOK_ENV \ 118 | --ngrok-secret $NGROK_AUTH_TOKEN # optional 119 | ``` 120 | 121 | ## Install 122 | 123 | ```bash 124 | # npm 125 | $ npm i -g twitter-autohook 126 | 127 | # Yarn 128 | $ yarn global add twitter-autohook 129 | ``` 130 | -------------------------------------------------------------------------------- /bearer-token/index.js: -------------------------------------------------------------------------------- 1 | const { post } = require('../client'); 2 | const { BearerTokenError } = require('../errors') 3 | 4 | let _bearerToken = null; 5 | const bearerToken = async (auth) => { 6 | if (_bearerToken) { 7 | return _bearerToken; 8 | } 9 | 10 | const requestConfig = { 11 | url: 'https://api.twitter.com/oauth2/token', 12 | options: { 13 | username: auth.consumer_key, 14 | password: auth.consumer_secret 15 | }, 16 | body: 'grant_type=client_credentials', 17 | }; 18 | 19 | const response = await post(requestConfig); 20 | if (response.statusCode !== 200) { 21 | throw new BearerTokenError(response); 22 | } 23 | 24 | _bearerToken = response.body.access_token; 25 | return _bearerToken; 26 | } 27 | 28 | module.exports = bearerToken; -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { 3 | Autohook, 4 | TooManyWebhooksError, 5 | UserSubscriptionError, 6 | } = require('.'); 7 | const path = require('path'); 8 | const os = require('os'); 9 | 10 | require('dotenv').config({path: path.resolve(os.homedir(), '.env.twitter')}); 11 | 12 | 13 | const argv = require('commander') 14 | .description('Zero configuration setup Twitter Account Activity API webhooks (Premium).\n\nAll parameters are optional if the corresponding env variable is defined in your env file or in ~/.env.twitter.') 15 | .option('--token ', 'your OAuth access token. (Env var: TWITTER_ACCESS_TOKEN)') 16 | .option('--secret ', 'your OAuth access token secret. (Env var: TWITTER_ACCESS_TOKEN_SECRET)') 17 | .option('--consumer-key ', 'your OAuth consumer key. (Env var: TWITTER_CONSUMER_KEY)') 18 | .option('--consumer-secret ', 'your OAuth consumer secret. (Env var: TWITTER_CONSUMER_SECRET)') 19 | .option('--ngrok-secret ', 'your ngrok authtoken. (Env var: NGROK_AUTH_TOKEN)') 20 | .option('--env ', 'your Premium environment label as defined in https://developer.twitter.com/en/account/environments. (Env var: TWITTER_WEBHOOK_ENV)') 21 | .option('--port ', 'port where the local HTTP server should run. Default: 1337. (Env var: PORT)') 22 | .option('--url ', 'URL to an existing webhook configured to respond to Twitter') 23 | .option('-s, --subscribe-me', 'subscribes the app to listen to activities from your user context specified by the current access token credentials') 24 | .option('--subscribe ', 'subscribes to activities of the Twitter user idenfified by the specified OAuth credentials', (val, prev) => { 25 | const [oauth_token, oauth_secret] = val.split(':'); 26 | const oauth = {oauth_token, oauth_secret}; 27 | prev.push(oauth); 28 | return prev; 29 | }, []) 30 | .option('-r, --reset', 'remove existing webhooks from the specified environment before starting a new instance') 31 | .parse(process.argv); 32 | 33 | const webhook = new Autohook({ 34 | token: argv.token || process.env.TWITTER_ACCESS_TOKEN, 35 | token_secret: argv.secret || process.env.TWITTER_ACCESS_TOKEN_SECRET, 36 | consumer_key: argv.consumerKey || process.env.TWITTER_CONSUMER_KEY, 37 | consumer_secret: argv.consumerSecret || process.env.TWITTER_CONSUMER_SECRET, 38 | env: argv.env || process.env.TWITTER_WEBHOOK_ENV, 39 | port: argv.port || process.env.PORT, 40 | }); 41 | 42 | webhook.on('event', event => console.log(JSON.stringify(event, null, 2))); 43 | 44 | const subscribe = async (auth) => { 45 | try { 46 | webhook.subscribe(auth); 47 | } catch(e) { 48 | console.error(e.message); 49 | process.exit(-1); 50 | } 51 | 52 | } 53 | 54 | (async () => { 55 | if (!!argv.reset) { 56 | await webhook.removeWebhooks(); 57 | } 58 | 59 | try { 60 | await webhook.start(argv.url || null); 61 | } catch(e) { 62 | switch (e.constructor) { 63 | case TooManyWebhooksError: 64 | console.error('Cannot add webhook: you have exceeded the number of webhooks available', 65 | `to you for the '${argv.env || process.env.TWITTER_WEBHOOK_ENV}' environment.`, 66 | `Use 'autohook -r' to remove your existing webhooks or remove callbacks manually`, 67 | 'using the Twitter API.'); 68 | break; 69 | default: 70 | console.error('Error:', e.message); 71 | break; 72 | } 73 | 74 | process.exit(-1); 75 | 76 | } 77 | 78 | if (!!argv.subscribeMe) { 79 | await subscribe({ 80 | oauth_token: argv.token || process.env.TWITTER_ACCESS_TOKEN, 81 | oauth_token_secret: argv.secret || process.env.TWITTER_ACCESS_TOKEN_SECRET, 82 | }); 83 | } 84 | 85 | for (oauth in argv.subscribe) { 86 | await subscribe(oauth); 87 | } 88 | 89 | })(); 90 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | const needle = require('needle'); 2 | const package = require('../package.json'); 3 | const { oauth } = require('../oauth'); 4 | needle.defaults({user_agent: `${package.name}/${package.version}`}); 5 | 6 | const auth = (method, url, options, body) => { 7 | try { 8 | Reflect.getPrototypeOf(options); 9 | } catch (e) { 10 | return {}; 11 | } 12 | 13 | options.headers = options.headers || {}; 14 | if (options.oauth) { 15 | options.headers.authorization = oauth(url, method, options, !!options.json ? {} : body); 16 | } else if (options.bearer) { 17 | options.headers.authorization = `Bearer ${options.bearer}`; 18 | } 19 | 20 | return options; 21 | } 22 | 23 | const get = ({url, ...options}) => { 24 | method = 'GET'; 25 | options.options = auth(method, url, options.options); 26 | return needle(method, url, null, options.options); 27 | } 28 | 29 | const del = ({url, ...options}) => { 30 | method = 'DELETE'; 31 | options.options = auth(method, url, options.options); 32 | return needle(method, url, null, options.options); 33 | } 34 | 35 | const post = ({url, body = {}, ...options}) => { 36 | method = 'POST'; 37 | options.options = auth(method, url, options.options, body); 38 | return needle(method, url, body, options.options); 39 | } 40 | 41 | const put = ({url, body = {}, ...options}) => { 42 | method = 'PUT'; 43 | options.options = auth(method, url, options.options, body); 44 | return needle(method, url, body, options.options); 45 | } 46 | 47 | module.exports = { get, del, post, put }; 48 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdevplatform/autohook/b872e8464f4f580cc71d85604fff810782c03627/demo.gif -------------------------------------------------------------------------------- /env.twitter.template: -------------------------------------------------------------------------------- 1 | TWITTER_CONSUMER_KEY= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ API key 2 | TWITTER_CONSUMER_SECRET= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ API secret key 3 | TWITTER_ACCESS_TOKEN= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ Access token 4 | TWITTER_ACCESS_TOKEN_SECRET= # https://developer.twitter.com/en/apps ➡️ Your app ID ➡️ Details ➡️ Access token secret 5 | TWITTER_WEBHOOK_ENV= # https://developer.twitter.com/en/account/environments ➡️ One of 'Dev environment label' or 'Prod environment label' 6 | NGROK_AUTH_TOKEN= # https://ngrok.com/ - Create a free account to get your auth token -------------------------------------------------------------------------------- /errors/index.js: -------------------------------------------------------------------------------- 1 | class TwitterError extends Error { 2 | constructor({body, statusCode = null}, message = null, code = null) { 3 | if (message === null && code === null && Array.isArray(body.errors)) { 4 | message = body.errors[0].message; 5 | code = body.errors[0].code; 6 | } 7 | 8 | super(`${message}` + (code ? ` (HTTP status: ${statusCode}, Twitter code: ${code})` : '')); 9 | this.name = this.constructor.name; 10 | this.code = code; 11 | Error.captureStackTrace(this, this.constructor); 12 | } 13 | } 14 | 15 | class WebhookURIError extends TwitterError {} 16 | class UserSubscriptionError extends TwitterError {} 17 | class RateLimitError extends Error { 18 | constructor({body, req = {}, headers = {}, statusCode = null}, message = null, code = null) { 19 | 20 | if (message === null && code === null && Array.isArray(body.errors)) { 21 | message = body.errors[0].message; 22 | code = body.errors[0].code; 23 | } 24 | 25 | if (typeof headers !== 'undefined' && typeof headers['x-rate-limit-limit'] !== 'undefined' && typeof headers['x-rate-limit-reset'] !== 'undefined') { 26 | const requestAllowed = headers['x-rate-limit-limit']; 27 | const resetAt = headers['x-rate-limit-reset'] * 1000 - (new Date().getTime()); 28 | const resetAtMin = Math.round(resetAt / 60 / 1000); 29 | super(`You exceeded the rate limit for ${req.path} (${requestAllowed} requests available, 0 remaining). Wait ${resetAtMin} minutes before trying again.`); 30 | } else { 31 | super(`You exceeded the rate limit for ${req.path}. Wait until rate limit resets and try again.`); 32 | } 33 | 34 | this.resetAt = headers['x-rate-limit-reset'] * 1000; 35 | this.name = this.constructor.name; 36 | this.code = code; 37 | Error.captureStackTrace(this, this.constructor); 38 | } 39 | } 40 | class TooManySubscriptionsError extends TwitterError {} 41 | class AuthenticationError extends TwitterError {} 42 | class BearerTokenError extends TwitterError {} 43 | 44 | const tryError = (response, defaultError = (response) => new TwitterError(response)) => { 45 | switch (response.statusCode) { 46 | case 200: 47 | case 201: 48 | case 204: 49 | return false; 50 | case 400: 51 | case 401: 52 | case 403: 53 | throw new AuthenticationError(response); 54 | case 420: 55 | case 429: 56 | throw new RateLimitError(response); 57 | default: 58 | throw defaultError(response); 59 | } 60 | }; 61 | 62 | module.exports = { 63 | TwitterError, 64 | WebhookURIError, 65 | UserSubscriptionError, 66 | TooManySubscriptionsError, 67 | RateLimitError, 68 | AuthenticationError, 69 | BearerTokenError, 70 | tryError, 71 | }; -------------------------------------------------------------------------------- /examples/listen.js: -------------------------------------------------------------------------------- 1 | const {Autohook} = require('..'); 2 | 3 | const qs = require('querystring'); 4 | const request = require('request'); 5 | const readline = require('readline').createInterface({ 6 | input: process.stdin, 7 | output: process.stdout 8 | }); 9 | const util = require('util'); 10 | const path = require('path'); 11 | const os = require('os'); 12 | const URL = require('url').URL; 13 | 14 | const get = util.promisify(request.get); 15 | const post = util.promisify(request.post); 16 | const sleep = util.promisify(setTimeout); 17 | 18 | const requestTokenURL = new URL('https://api.twitter.com/oauth/request_token'); 19 | const accessTokenURL = new URL('https://api.twitter.com/oauth/access_token'); 20 | const authorizeURL = new URL('https://api.twitter.com/oauth/authorize'); 21 | 22 | async function input(prompt) { 23 | return new Promise(async (resolve, reject) => { 24 | readline.question(prompt, (out) => { 25 | readline.close(); 26 | resolve(out); 27 | }); 28 | }); 29 | } 30 | 31 | async function accessToken({oauth_token, oauth_token_secret}, verifier) { 32 | const oAuthConfig = { 33 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 34 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 35 | token: oauth_token, 36 | token_secret: oauth_token_secret, 37 | verifier: verifier, 38 | }; 39 | 40 | const req = await post({url: accessTokenURL, oauth: oAuthConfig}); 41 | if (req.body) { 42 | return qs.parse(req.body); 43 | } else { 44 | throw new Error('Cannot get an OAuth access token'); 45 | } 46 | } 47 | 48 | async function requestToken() { 49 | const oAuthConfig = { 50 | callback: 'oob', 51 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 52 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 53 | }; 54 | 55 | const req = await post({url: requestTokenURL, oauth: oAuthConfig}); 56 | if (req.body) { 57 | return qs.parse(req.body); 58 | } else { 59 | throw new Error('Cannot get an OAuth request token'); 60 | } 61 | } 62 | 63 | async function markAsRead(messageId, senderId, auth) { 64 | const requestConfig = { 65 | url: 'https://api.twitter.com/1.1/direct_messages/mark_read.json', 66 | form: { 67 | last_read_event_id: messageId, 68 | recipient_id: senderId, 69 | }, 70 | oauth: auth, 71 | }; 72 | 73 | await post(requestConfig); 74 | } 75 | 76 | async function indicateTyping(senderId, auth) { 77 | const requestConfig = { 78 | url: 'https://api.twitter.com/1.1/direct_messages/indicate_typing.json', 79 | form: { 80 | recipient_id: senderId, 81 | }, 82 | oauth: auth, 83 | }; 84 | 85 | await post(requestConfig); 86 | } 87 | 88 | async function sayHi(event, oauth) { 89 | // Only react to direct messages 90 | if (!event.direct_message_events) { 91 | return; 92 | } 93 | 94 | const message = event.direct_message_events.shift(); 95 | 96 | // Filter out empty messages or non-message events 97 | if (typeof message === 'undefined' || typeof message.message_create === 'undefined') { 98 | return; 99 | } 100 | 101 | // Filter out messages created by the the authenticating users (to avoid sending messages to oneself) 102 | if (message.message_create.sender_id === message.message_create.target.recipient_id) { 103 | return; 104 | } 105 | 106 | const oAuthConfig = { 107 | token: oauth.oauth_token, 108 | token_secret: oauth.oauth_token_secret, 109 | consumer_key: oauth.consumer_key, 110 | consumer_secret: oauth.consumer_secret, 111 | }; 112 | 113 | 114 | await markAsRead(message.message_create.id, message.message_create.sender_id, oAuthConfig); 115 | await indicateTyping(message.message_create.sender_id, oAuthConfig); 116 | const senderScreenName = event.users[message.message_create.sender_id].screen_name; 117 | 118 | console.log(`${senderScreenName} says ${message.message_create.message_data.text}`); 119 | 120 | const requestConfig = { 121 | url: 'https://api.twitter.com/1.1/direct_messages/events/new.json', 122 | oauth: oAuthConfig, 123 | json: { 124 | event: { 125 | type: 'message_create', 126 | message_create: { 127 | target: { 128 | recipient_id: message.message_create.sender_id, 129 | }, 130 | message_data: { 131 | text: `Hi @${senderScreenName}! 👋`, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }; 137 | await post(requestConfig); 138 | } 139 | 140 | (async () => { 141 | try { 142 | 143 | // Get request token 144 | const oAuthRequestToken = await requestToken(); 145 | 146 | // Get authorization 147 | authorizeURL.searchParams.append('oauth_token', oAuthRequestToken.oauth_token); 148 | console.log('Please go here and authorize:', authorizeURL.href); 149 | const pin = await input('Paste the PIN here: '); 150 | 151 | // Get the access token 152 | const userToMonitor = await accessToken(oAuthRequestToken, pin.trim()); 153 | const webhook = new Autohook({ 154 | token: process.env.TWITTER_ACCESS_TOKEN, 155 | token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET, 156 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 157 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 158 | env: process.env.TWITTER_WEBHOOK_ENV}); 159 | 160 | webhook.on('event', async (event) => { 161 | await sayHi(event, { 162 | oauth_token: userToMonitor.oauth_token, 163 | oauth_token_secret: userToMonitor.oauth_token_secret, 164 | user_id: userToMonitor.user_id, 165 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 166 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 167 | reset: true, 168 | }); 169 | }); 170 | 171 | webhook.on('event', async (event) => { 172 | console.log('We received an event!'); 173 | await sayHi(event, { 174 | oauth_token: userToMonitor.oauth_token, 175 | oauth_token_secret: userToMonitor.oauth_token_secret, 176 | user_id: userToMonitor.user_id, 177 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 178 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 179 | reset: true, 180 | }); 181 | }); 182 | 183 | await webhook.removeWebhooks(); 184 | await webhook.start(); 185 | await webhook.subscribe(userToMonitor); 186 | 187 | } catch(e) { 188 | console.error(e); 189 | process.exit(-1); 190 | } 191 | })(); -------------------------------------------------------------------------------- /examples/standalone-server.js: -------------------------------------------------------------------------------- 1 | const {Autohook, validateWebhook, validateSignature} = require('..'); 2 | 3 | const url = require('url'); 4 | const ngrok = require('ngrok'); 5 | const http = require('http'); 6 | 7 | const PORT = process.env.PORT || 4242; 8 | 9 | const startServer = (port, auth) => http.createServer((req, res) => { 10 | const route = url.parse(req.url, true); 11 | 12 | if (!route.pathname) { 13 | return; 14 | } 15 | 16 | if (route.query.crc_token) { 17 | try { 18 | if (!validateSignature(req.headers, auth, url.parse(req.url).query)) { 19 | console.error('Cannot validate webhook signature'); 20 | return; 21 | }; 22 | } catch (e) { 23 | console.error(e); 24 | } 25 | 26 | const crc = validateWebhook(route.query.crc_token, auth, res); 27 | res.writeHead(200, {'content-type': 'application/json'}); 28 | res.end(JSON.stringify(crc)); 29 | } 30 | 31 | if (req.method === 'POST' && req.headers['content-type'] === 'application/json') { 32 | let body = ''; 33 | req.on('data', chunk => { 34 | body += chunk.toString(); 35 | }); 36 | req.on('end', () => { 37 | try { 38 | if (!validateSignature(req.headers, auth, body)) { 39 | console.error('Cannot validate webhook signature'); 40 | return; 41 | }; 42 | } catch (e) { 43 | console.error(e); 44 | } 45 | 46 | console.log('Event received:', body); 47 | res.writeHead(200); 48 | res.end(); 49 | }); 50 | } 51 | }).listen(port); 52 | 53 | (async () => { 54 | try { 55 | const NGROK_AUTH_TOKEN = process.env.NGROK_AUTH_TOKEN; 56 | if (NGROK_AUTH_TOKEN) { 57 | await ngrok.authtoken(process.env.NGROK_AUTH_TOKEN); 58 | } 59 | const url = await ngrok.connect(PORT); 60 | const webhookURL = `${url}/standalone-server/webhook`; 61 | 62 | const config = { 63 | token: process.env.TWITTER_ACCESS_TOKEN, 64 | token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET, 65 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 66 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 67 | env: process.env.TWITTER_WEBHOOK_ENV, 68 | }; 69 | 70 | const server = startServer(PORT, config); 71 | 72 | 73 | const webhook = new Autohook(config); 74 | await webhook.removeWebhooks(); 75 | await webhook.start(webhookURL); 76 | await webhook.subscribe({ 77 | oauth_token: config.token, 78 | oauth_token_secret: config.token_secret, 79 | }); 80 | 81 | } catch(e) { 82 | console.error(e); 83 | process.exit(-1); 84 | } 85 | })(); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* global process, Buffer */ 3 | const ngrok = require('ngrok'); 4 | const http = require('http'); 5 | const url = require('url'); 6 | const crypto = require('crypto'); 7 | const path = require('path'); 8 | const os = require('os'); 9 | const EventEmitter = require('events'); 10 | const URL = require('url').URL; 11 | const bearerToken = require('./bearer-token'); 12 | const { get, post, del } = require('./client'); 13 | 14 | const { 15 | TooManySubscriptionsError, 16 | UserSubscriptionError, 17 | WebhookURIError, 18 | tryError, 19 | } = require('./errors'); 20 | 21 | require('dotenv').config({path: path.resolve(os.homedir(), '.env.twitter')}); 22 | 23 | const DEFAULT_PORT = 1337; 24 | const WEBHOOK_ROUTE = '/webhook'; 25 | 26 | let _getSubscriptionsCount = null; 27 | const getSubscriptionsCount = async (auth) => { 28 | if (_getSubscriptionsCount) { 29 | return _getSubscriptionsCount; 30 | } 31 | 32 | const token = await bearerToken(auth); 33 | const requestConfig = { 34 | url: 'https://api.twitter.com/1.1/account_activity/all/subscriptions/count.json', 35 | options: { 36 | bearer: token 37 | }, 38 | }; 39 | 40 | const response = await get(requestConfig); 41 | 42 | const error = tryError(response); 43 | if (error) { 44 | throw error; 45 | } 46 | 47 | _getSubscriptionsCount = response.body; 48 | return _getSubscriptionsCount; 49 | }; 50 | 51 | const updateSubscriptionCount = (increment) => { 52 | if (!_getSubscriptionsCount) { 53 | return; 54 | } 55 | 56 | _getSubscriptionsCount.subscriptions_count += increment; 57 | }; 58 | 59 | const deleteWebhooks = async (webhooks, auth, env) => { 60 | console.log('Removing webhooks…'); 61 | for (const {id, url} of webhooks) { 62 | const requestConfig = { 63 | url: `https://api.twitter.com/1.1/account_activity/all/${env}/webhooks/${id}.json`, 64 | options: { 65 | oauth: auth, 66 | }, 67 | }; 68 | 69 | console.log(`Removing ${url}…`); 70 | await del(requestConfig); 71 | } 72 | }; 73 | 74 | const validateWebhook = (token, auth) => { 75 | const responseToken = crypto.createHmac('sha256', auth.consumer_secret).update(token).digest('base64'); 76 | return {response_token: `sha256=${responseToken}`}; 77 | }; 78 | 79 | const validateSignature = (header, auth, body) => { 80 | const signatureHeaderName = 'x-twitter-webhooks-signature'; 81 | 82 | if (typeof header[signatureHeaderName] === 'undefined') { 83 | throw new TypeError(`validateSignature: header ${signatureHeaderName} not found`); 84 | } 85 | 86 | const signature = 'sha256=' + crypto 87 | .createHmac('sha256', auth.consumer_secret) 88 | .update(body) 89 | .digest('base64'); 90 | 91 | return crypto.timingSafeEqual( 92 | Buffer.from(header[signatureHeaderName]), 93 | Buffer.from(signature)); 94 | }; 95 | 96 | const verifyCredentials = async (auth) => { 97 | const requestConfig = { 98 | url: 'https://api.twitter.com/1.1/account/verify_credentials.json', 99 | options: { 100 | oauth: auth, 101 | }, 102 | }; 103 | 104 | const response = await get(requestConfig); 105 | const error = tryError( 106 | response, 107 | (response) => new UserSubscriptionError(response)); 108 | 109 | if (error) { 110 | throw error; 111 | } 112 | 113 | return response.body.screen_name; 114 | }; 115 | 116 | class Autohook extends EventEmitter { 117 | constructor({ 118 | token = (process.env.TWITTER_ACCESS_TOKEN || '').trim(), 119 | token_secret = (process.env.TWITTER_ACCESS_TOKEN_SECRET || '').trim(), 120 | consumer_key = (process.env.TWITTER_CONSUMER_KEY || '').trim(), 121 | consumer_secret = (process.env.TWITTER_CONSUMER_SECRET || '').trim(), 122 | ngrok_secret = (process.env.NGROK_AUTH_TOKEN || '').trim(), 123 | env = (process.env.TWITTER_WEBHOOK_ENV || '').trim(), 124 | port = process.env.PORT || DEFAULT_PORT, 125 | headers = [], 126 | } = {}) { 127 | 128 | Object.entries({token, token_secret, consumer_key, consumer_secret, env, port}).map((el) => { 129 | const [key, value] = el; 130 | if (!value) { 131 | throw new TypeError(`'${key}' is empty or not set. Check your configuration and try again.`); 132 | } 133 | }); 134 | 135 | super(); 136 | this.auth = {token, token_secret, consumer_key, consumer_secret}; 137 | this.ngrokSecret = ngrok_secret; 138 | this.env = env; 139 | this.port = port; 140 | this.headers = headers; 141 | } 142 | 143 | startServer() { 144 | this.server = http.createServer((req, res) => { 145 | const route = url.parse(req.url, true); 146 | 147 | if (!route.pathname) { 148 | return; 149 | } 150 | 151 | if (route.query.crc_token) { 152 | try { 153 | if (!validateSignature(req.headers, this.auth, url.parse(req.url).query)) { 154 | console.error('Cannot validate webhook signature'); 155 | return; 156 | } 157 | } catch (e) { 158 | console.error(e); 159 | } 160 | const crc = validateWebhook(route.query.crc_token, this.auth); 161 | res.writeHead(200, {'content-type': 'application/json'}); 162 | res.end(JSON.stringify(crc)); 163 | } 164 | 165 | if (req.method === 'POST' && req.headers['content-type'] === 'application/json') { 166 | let body = ''; 167 | req.on('data', (chunk) => { 168 | body += chunk.toString(); 169 | }); 170 | req.on('end', () => { 171 | try { 172 | if (!validateSignature(req.headers, this.auth, body)) { 173 | console.error('Cannot validate webhook signature'); 174 | return; 175 | } 176 | } catch (e) { 177 | console.error(e); 178 | } 179 | this.emit('event', JSON.parse(body), req); 180 | res.writeHead(200); 181 | res.end(); 182 | }); 183 | } 184 | }).listen(this.port); 185 | } 186 | 187 | async setWebhook(webhookUrl) { 188 | const parsedUrl = url.parse(webhookUrl); 189 | if (parsedUrl.protocol === null || parsedUrl.host === 'null') { 190 | throw new TypeError(`${webhookUrl} is not a valid URL. Please provide a valid URL and try again.`); 191 | } else if (parsedUrl.protocol !== 'https:') { 192 | throw new TypeError(`${webhookUrl} is not a valid URL. Your webhook must be HTTPS.`); 193 | } 194 | 195 | console.log(`Registering ${webhookUrl} as a new webhook…`); 196 | const endpoint = new URL(`https://api.twitter.com/1.1/account_activity/all/${this.env}/webhooks.json`); 197 | endpoint.searchParams.append('url', webhookUrl); 198 | 199 | const requestConfig = { 200 | url: endpoint.toString(), 201 | options: { 202 | oauth: this.auth, 203 | }, 204 | }; 205 | 206 | const response = await post(requestConfig); 207 | 208 | const error = tryError( 209 | response, 210 | (response) => new URIError(response, [ 211 | `Cannot get webhooks. Please check that '${this.env}' is a valid environment defined in your`, 212 | 'Developer dashboard at https://developer.twitter.com/en/account/environments, and that', 213 | `your OAuth credentials are valid and can access '${this.env}'. (HTTP status: ${response.statusCode})`].join(' ')) 214 | ); 215 | 216 | if (error) { 217 | throw error; 218 | } 219 | 220 | return response.body; 221 | } 222 | 223 | async getWebhooks() { 224 | console.log('Getting webhooks…'); 225 | 226 | let token = null; 227 | try { 228 | token = await bearerToken(this.auth); 229 | } catch (e) { 230 | token = null; 231 | throw e; 232 | } 233 | 234 | const requestConfig = { 235 | url: `https://api.twitter.com/1.1/account_activity/all/${this.env}/webhooks.json`, 236 | options: { 237 | bearer: token, 238 | }, 239 | }; 240 | 241 | const response = await get(requestConfig); 242 | const error = tryError( 243 | response, 244 | (response) => new URIError(response, [ 245 | `Cannot get webhooks. Please check that '${this.env}' is a valid environment defined in your`, 246 | 'Developer dashboard at https://developer.twitter.com/en/account/environments, and that', 247 | `your OAuth credentials are valid and can access '${this.env}'. (HTTP status: ${response.statusCode})`].join(' '))); 248 | 249 | if (error) { 250 | throw error; 251 | } 252 | 253 | return response.body; 254 | } 255 | 256 | async removeWebhook(webhook) { 257 | await deleteWebhooks([webhook], this.auth, this.env); 258 | } 259 | 260 | async removeWebhooks() { 261 | const webhooks = await this.getWebhooks(this.auth, this.env); 262 | await deleteWebhooks(webhooks, this.auth, this.env); 263 | } 264 | 265 | async start(webhookUrl = null) { 266 | 267 | if (!webhookUrl) { 268 | this.startServer(); 269 | if (this.ngrokSecret) { 270 | await ngrok.authtoken(this.ngrokSecret); 271 | } 272 | const url = await ngrok.connect(this.port); 273 | webhookUrl = `${url}${WEBHOOK_ROUTE}`; 274 | } 275 | 276 | try { 277 | await this.setWebhook(webhookUrl); 278 | console.log('Webhook created.'); 279 | } catch (e) { 280 | console.log('Cannot create webhook:', e); 281 | throw e; 282 | } 283 | } 284 | 285 | async subscribe({oauth_token, oauth_token_secret, screen_name = null}) { 286 | const auth = { 287 | consumer_key: this.auth.consumer_key, 288 | consumer_secret: this.auth.consumer_secret, 289 | token: oauth_token.trim(), 290 | token_secret: oauth_token_secret.trim(), 291 | }; 292 | 293 | try { 294 | screen_name = screen_name || await verifyCredentials(auth); 295 | } catch (e) { 296 | screen_name = null; 297 | throw e; 298 | } 299 | 300 | const {subscriptions_count, provisioned_count} = await getSubscriptionsCount(auth); 301 | 302 | if (subscriptions_count === provisioned_count) { 303 | throw new TooManySubscriptionsError([`Cannot subscribe to ${screen_name}'s activities:`, 304 | 'you exceeded the number of subscriptions available to you.', 305 | 'Please remove a subscription or upgrade your premium access at', 306 | 'https://developer.twitter.com/apps.', 307 | ].join(' ')); 308 | } 309 | 310 | const requestConfig = { 311 | url: `https://api.twitter.com/1.1/account_activity/all/${this.env}/subscriptions.json`, 312 | options: { 313 | oauth: auth, 314 | }, 315 | }; 316 | 317 | const response = await post(requestConfig); 318 | const error = tryError( 319 | response, 320 | (response) => new UserSubscriptionError(response)); 321 | 322 | if (error) { 323 | throw error; 324 | } 325 | 326 | console.log(`Subscribed to ${screen_name}'s activities.`); 327 | updateSubscriptionCount(1); 328 | return true; 329 | } 330 | 331 | async unsubscribe(userId) { 332 | const token = await bearerToken(this.auth); 333 | const requestConfig = { 334 | url: `https://api.twitter.com/1.1/account_activity/all/${this.env}/subscriptions/${userId}.json`, 335 | options: { 336 | bearer: token 337 | }, 338 | }; 339 | 340 | const response = await del(requestConfig); 341 | const error = tryError( 342 | response, 343 | (response) => new UserSubscriptionError(response)); 344 | 345 | if (error) { 346 | throw error; 347 | } 348 | 349 | console.log(`Unsubscribed from ${userId}'s activities.`); 350 | updateSubscriptionCount(-1); 351 | return true; 352 | } 353 | } 354 | 355 | module.exports = { 356 | Autohook, 357 | WebhookURIError, 358 | UserSubscriptionError, 359 | TooManySubscriptionsError, 360 | validateWebhook, 361 | validateSignature, 362 | }; -------------------------------------------------------------------------------- /oauth/index.js: -------------------------------------------------------------------------------- 1 | const { URL } = require('url'); 2 | const qs = require('querystring'); 3 | const crypto = require('crypto'); 4 | 5 | const encode = (str) => 6 | encodeURIComponent(str) 7 | .replace(/!/g,'%21') 8 | .replace(/\*/g,'%2A') 9 | .replace(/\(/g,'%28') 10 | .replace(/\)/g,'%29') 11 | .replace(/'/g,'%27'); 12 | 13 | const oAuthFunctions = { 14 | nonceFn: () => crypto.randomBytes(16).toString('base64'), 15 | timestampFn: () => Math.floor(Date.now() / 1000).toString(), 16 | }; 17 | 18 | const setNonceFn = (fn) => { 19 | if (typeof fn !== 'function') { 20 | throw new TypeError(`OAuth: setNonceFn expects a function`) 21 | } 22 | 23 | oAuthFunctions.nonceFn = fn; 24 | }; 25 | 26 | const setTimestampFn = (fn) => { 27 | if (typeof fn !== 'function') { 28 | throw new TypeError(`OAuth: setTimestampFn expects a function`) 29 | } 30 | 31 | oAuthFunctions.timestampFn = fn; 32 | } 33 | 34 | const parameters = (url, auth, body = {}) => { 35 | let params = {}; 36 | 37 | const urlObject = new URL(url); 38 | for (const key of urlObject.searchParams.keys()) { 39 | params[key] = urlObject.searchParams.get(key); 40 | } 41 | 42 | if (typeof body === 'string') { 43 | body = qs.parse(body); 44 | } 45 | 46 | if (Object.prototype.toString.call(body) !== '[object Object]') { 47 | throw new TypeError('OAuth: body parameters must be string or object'); 48 | } 49 | 50 | params = Object.assign(params, body); 51 | 52 | params.oauth_consumer_key = auth.consumer_key; 53 | params.oauth_token = auth.token; 54 | params.oauth_nonce = oAuthFunctions.nonceFn(); 55 | params.oauth_timestamp = oAuthFunctions.timestampFn(); 56 | params.oauth_signature_method = 'HMAC-SHA1'; 57 | params.oauth_version = '1.0'; 58 | 59 | return params; 60 | } 61 | 62 | const parameterString = (url, auth, params) => { 63 | const sortedKeys = Object.keys(params).sort(); 64 | 65 | let sortedParams = []; 66 | for (const key of sortedKeys) { 67 | sortedParams.push(`${key}=${encode(params[key])}`); 68 | } 69 | 70 | return sortedParams.join('&'); 71 | } 72 | 73 | const hmacSha1Signature = (baseString, signingKey) => 74 | crypto 75 | .createHmac('sha1', signingKey) 76 | .update(baseString) 77 | .digest('base64'); 78 | 79 | const signatureBaseString = (url, method, paramString) => { 80 | const urlObject = new URL(url); 81 | const baseURL = urlObject.origin + urlObject.pathname; 82 | return `${method.toUpperCase()}&${encode(baseURL)}&${encode(paramString)}`; 83 | } 84 | 85 | const createSigningKey = ({consumer_secret, token_secret}) => `${encode(consumer_secret)}&${encode(token_secret)}`; 86 | 87 | const header = (url, auth, signature, params) => { 88 | params.oauth_signature = signature; 89 | const sortedKeys = Object.keys(params).sort(); 90 | 91 | const sortedParams = []; 92 | for (const key of sortedKeys) { 93 | if (key.indexOf('oauth_') !== 0) { 94 | continue; 95 | } 96 | 97 | sortedParams.push(`${key}="${encode(params[key])}"`); 98 | } 99 | 100 | return `OAuth ${sortedParams.join(', ')}`; 101 | 102 | } 103 | 104 | const oauth = (url, method, {oauth}, body) => { 105 | const params = parameters(url, oauth, body); 106 | const paramString = parameterString(url, oauth, params); 107 | const baseString = signatureBaseString(url, method, paramString); 108 | const signingKey = createSigningKey(oauth); 109 | const signature = hmacSha1Signature(baseString, signingKey); 110 | const signatureHeader = header(url, oauth, signature, params); 111 | return signatureHeader; 112 | } 113 | 114 | module.exports = {oauth, setNonceFn, setTimestampFn}; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-autohook", 3 | "version": "1.7.2", 4 | "description": "Automatically setup and serve webhooks for the Twitter Account Activity API", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/twitterdev/autohook" 8 | }, 9 | "main": "index.js", 10 | "bin": { 11 | "autohook": "./cli.js" 12 | }, 13 | "author": "Twitter", 14 | "license": "MIT", 15 | "keywords": [ 16 | "twitter", 17 | "twitter-api", 18 | "webhooks" 19 | ], 20 | "dependencies": { 21 | "commander": "^2.20.0", 22 | "dotenv": "^8.0.0", 23 | "needle": "^2.3.3", 24 | "ngrok": "^3.2.1", 25 | "nock": "^12.0.2" 26 | }, 27 | "devDependencies": { 28 | "eslint": "^7.6.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdevplatform/autohook/b872e8464f4f580cc71d85604fff810782c03627/screenshot.png -------------------------------------------------------------------------------- /test/autohook-error.js: -------------------------------------------------------------------------------- 1 | const { Autohook } = require('../'); 2 | const nock = require('nock'); 3 | const assert = require('assert'); 4 | const oauth = { 5 | consumer_key: 'test', 6 | consumer_secret: 'test', 7 | token: 'test', 8 | token_secret: 'test', 9 | }; 10 | 11 | const errorMessage = 'test error'; 12 | const response = { 13 | statusCode: 403, 14 | body: { 15 | errors: [{ 16 | message: errorMessage, 17 | code: 1337, 18 | }], 19 | }, 20 | req: { 21 | path: '/example', 22 | }, 23 | headers: { 24 | 'x-rate-limit-reset': new Date().getTime(), 25 | } 26 | }; 27 | 28 | const environment = 'test'; 29 | const userId = '100001337'; 30 | const webhookId = '133700001337'; 31 | const webhookUrl = 'https://example.com/webhook/1'; 32 | 33 | const webhook = new Autohook({...oauth, env: environment}); 34 | 35 | // Bearer token error 36 | (async () => { 37 | response.statusCode = 403; 38 | const scope = nock('https://api.twitter.com') 39 | .post('/oauth2/token', 'grant_type=client_credentials') 40 | .reply(() => [response.statusCode, response.body, response.headers]) 41 | await assert.rejects(webhook.removeWebhooks(), { 42 | name: 'BearerTokenError' 43 | }); 44 | nock.cleanAll(); 45 | })(); 46 | 47 | 48 | // URIError on getWebhooks 49 | (async () => { 50 | response.statusCode = 555; 51 | nock.cleanAll(); 52 | const scope = nock('https://api.twitter.com') 53 | .post('/oauth2/token', 'grant_type=client_credentials') 54 | .reply(200, {token_type: 'bearer', access_token: 'test'}) 55 | .get(`/1.1/account_activity/all/${environment}/webhooks.json`) 56 | .reply(() => [response.statusCode, response.body, response.headers]) 57 | 58 | await assert.rejects(webhook.removeWebhooks(), { 59 | name: 'URIError' 60 | }); 61 | nock.cleanAll(); 62 | })(); 63 | 64 | // URIError on deleteWebhooks 65 | (async () => { 66 | response.statusCode = 555; 67 | const scope = nock('https://api.twitter.com') 68 | .post('/oauth2/token', 'grant_type=client_credentials') 69 | .reply(200, {token_type: 'bearer', access_token: 'test'}) 70 | .get(`/1.1/account_activity/all/${environment}/webhooks.json`) 71 | .reply(200, [{id: webhookId, url: webhookUrl}]) 72 | .delete(`/1.1/account_activity/all/${environment}/webhooks/${webhookId}.json`) 73 | .reply(() => [response.statusCode, response.body, response.headers]) 74 | 75 | await assert.rejects(webhook.removeWebhooks(), { 76 | name: 'URIError' 77 | }); 78 | nock.cleanAll(); 79 | })(); 80 | 81 | -------------------------------------------------------------------------------- /test/autohook-success.js: -------------------------------------------------------------------------------- 1 | const { Autohook } = require('../'); 2 | const nock = require('nock'); 3 | const assert = require('assert'); 4 | const oauth = { 5 | consumer_key: 'test', 6 | consumer_secret: 'test', 7 | token: 'test', 8 | token_secret: 'test', 9 | }; 10 | 11 | // Success 12 | (async () => { 13 | const environment = 'test'; 14 | const userId = '100001337'; 15 | const webhookId = '133700001337'; 16 | const webhookUrl = 'https://example.com/webhook/1'; 17 | 18 | const scope = nock('https://api.twitter.com') 19 | .post('/oauth2/token', 'grant_type=client_credentials') 20 | .reply(200, {token_type: 'bearer', access_token: 'test'}) 21 | .get('/1.1/account/verify_credentials.json') 22 | .reply(200, {screen_name: 'TestUser'}) 23 | .get(`/1.1/account_activity/all/${environment}/webhooks.json`) 24 | .reply(200, [ 25 | {id: webhookId, url: webhookUrl}, 26 | ]) 27 | .delete(`/1.1/account_activity/all/${environment}/webhooks/${webhookId}.json`) 28 | .reply(204) 29 | .post(`/1.1/account_activity/all/${environment}/webhooks.json?url=${encodeURIComponent(webhookUrl)}`) 30 | .reply(204) 31 | .post(`/1.1/account_activity/all/${environment}/subscriptions.json`) 32 | .reply(204) 33 | .get('/1.1/account_activity/all/subscriptions/count.json') 34 | .reply(200, {subscriptions_count: 0, provisioned_count: 15}) 35 | .delete(`/1.1/account_activity/all/${environment}/subscriptions/${userId}.json`) 36 | .reply(204) 37 | 38 | const webhook = new Autohook({...oauth, env: environment}); 39 | await assert.doesNotReject(webhook.removeWebhooks()); 40 | await assert.doesNotReject(webhook.start(webhookUrl)); 41 | let subscriptionStatus = null; 42 | 43 | await assert.doesNotReject(async () => { 44 | subscriptionStatus = await webhook.subscribe({oauth_token: oauth.token, oauth_token_secret: oauth.token_secret}); 45 | }); 46 | 47 | assert.strictEqual(subscriptionStatus, true); 48 | 49 | let unsubscriptionStatus = null; 50 | await assert.doesNotReject(async () => { 51 | unsubscriptionStatus = await webhook.unsubscribe(userId); 52 | }); 53 | assert.strictEqual(unsubscriptionStatus, true); 54 | scope.done(); 55 | })(); -------------------------------------------------------------------------------- /test/bearer-token.js: -------------------------------------------------------------------------------- 1 | const nock = require('nock'); 2 | const bearerToken = require('../bearer-token'); 3 | const assert = require('assert'); 4 | 5 | const tokenValue = 'access_token_from_api'; 6 | 7 | (async () => { 8 | const scope = nock('https://api.twitter.com') 9 | .post('/oauth2/token') 10 | .reply(200, {token_type: 'bearer', access_token: 'access_token_from_api'}); 11 | 12 | let token = null; 13 | await assert.doesNotReject(async () => { 14 | token = await bearerToken({ 15 | consumer_key: 'test_consumer_key', 16 | consumer_secret: 'test_consumer_secret', 17 | }); 18 | }); 19 | 20 | assert.equal(token, tokenValue); 21 | scope.done(); 22 | })(); 23 | 24 | (async () => { 25 | const scope = nock('https://api.twitter.com') 26 | .post('/oauth2/token') 27 | .reply(503, { 28 | errors: [{ 29 | message: 'test error', 30 | code: 1337, 31 | }], 32 | }); 33 | 34 | await assert.rejects(async () => { 35 | const token = await bearerToken({ 36 | consumer_key: 'test_consumer_key', 37 | consumer_secret: 'test_consumer_secret', 38 | }); 39 | }, { 40 | name: 'BearerTokenError', 41 | }); 42 | scope.done(); 43 | })(); 44 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | const { post } = require('../client'); 2 | const oauthInstance = require('../oauth'); 3 | const qs = require('querystring'); 4 | const nock = require('nock'); 5 | const assert = require('assert'); 6 | 7 | (async () => { 8 | const oAuthConfig = { 9 | consumer_key: 'consumer_key', 10 | consumer_secret: 'consumer_secret', 11 | token: 'test_user_token', 12 | token_secret: 'test_user_token_secret', 13 | }; 14 | 15 | const baseURL = 'https://example.com'; 16 | const formRoute = '/form'; 17 | const jsonRoute = '/json'; 18 | const oauthRoute = '/oauth'; 19 | 20 | const mockData = { 21 | form: { 22 | nonce: 'v9jKGGxfQiD2u09dgcFucmyCXKckcpGprlojZhxV4', 23 | timestamp: '1584380758', 24 | body: qs.stringify({test_value: 42, form: true, json: false}), 25 | contentType: 'application/x-www-form-urlencoded', 26 | oauth: 'OAuth oauth_consumer_key="consumer_key", oauth_nonce="v9jKGGxfQiD2u09dgcFucmyCXKckcpGprlojZhxV4", oauth_signature="7KNZQnQi9ZnV9289OmqE4Xg7dck%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1584380758", oauth_token="test_user_token", oauth_version="1.0"', 27 | }, 28 | json: { 29 | nonce: 'kCMhnYwha4MIuIIRVbYt62A9eoIboIP8', 30 | timestamp: '1584407161', 31 | body: {test_value: 42, form: false, json: true}, 32 | contentType: 'application/json; charset=utf-8', 33 | oauth: 'OAuth oauth_consumer_key="consumer_key", oauth_nonce="kCMhnYwha4MIuIIRVbYt62A9eoIboIP8", oauth_signature="4oV4jRsCON%2FbLeCePh5zR%2Bd73MI%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1584407161", oauth_token="test_user_token", oauth_version="1.0"', 34 | }, 35 | }; 36 | 37 | const replyCallback = function(route, requestBody) { 38 | return [200, { 39 | body: requestBody, 40 | headers: { 41 | contentType: this.req.headers['content-type'], 42 | oauth: this.req.headers.authorization || null, 43 | }, 44 | }]; 45 | }; 46 | 47 | const scope = nock(baseURL) 48 | .post(formRoute).reply(replyCallback) 49 | .post(jsonRoute).reply(replyCallback) 50 | .post(`${formRoute}${oauthRoute}`).reply(replyCallback) 51 | .post(`${jsonRoute}${oauthRoute}`).reply(replyCallback); 52 | 53 | const formResponse = await post({url: `${baseURL}${formRoute}`, body: mockData.form.body}); 54 | assert.deepEqual(formResponse.body.body, mockData.form.body); 55 | assert.equal(formResponse.body.headers.contentType, mockData.form.contentType); 56 | 57 | oauthInstance.setNonceFn(() => mockData.form.nonce); 58 | oauthInstance.setTimestampFn(() => mockData.form.timestamp); 59 | const formOAuthResponse = await post({url: `${baseURL}${formRoute}${oauthRoute}`, body: mockData.form.body, options: {oauth: oAuthConfig}}); 60 | assert.deepEqual(formOAuthResponse.body.body, mockData.form.body); 61 | assert.equal(formOAuthResponse.body.headers.contentType, mockData.form.contentType); 62 | assert.equal(formOAuthResponse.body.headers.oauth, mockData.form.oauth); 63 | 64 | const jsonResponse = await post({url: `${baseURL}${jsonRoute}`, body: mockData.json.body, options: {json: true}}); 65 | assert.deepEqual(jsonResponse.body.body, mockData.json.body); 66 | assert.equal(jsonResponse.body.headers.contentType, mockData.json.contentType); 67 | 68 | oauthInstance.setNonceFn(() => mockData.json.nonce); 69 | oauthInstance.setTimestampFn(() => mockData.json.timestamp); 70 | const jsonOAuthResponse = await post({url: `${baseURL}${jsonRoute}${oauthRoute}`, body: mockData.json.body, options: {oauth: oAuthConfig, json: true}}); 71 | assert.deepEqual(jsonOAuthResponse.body.body, mockData.json.body); 72 | assert.equal(jsonOAuthResponse.body.headers.contentType, mockData.json.contentType); 73 | assert.equal(jsonOAuthResponse.body.headers.oauth, mockData.json.oauth); 74 | 75 | nock.cleanAll(); 76 | })(); -------------------------------------------------------------------------------- /test/errors.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { 3 | AuthenticationError, 4 | TwitterError, 5 | UserSubscriptionError, 6 | WebhookURIError, 7 | RateLimitError, 8 | TooManySubscriptionsError, 9 | BearerTokenError, 10 | tryError, 11 | } = require('../errors'); 12 | const response = { 13 | statusCode: 200, 14 | body: { 15 | errors: [{ 16 | message: 'test error', 17 | code: 1337, 18 | }], 19 | }, 20 | req: { 21 | path: '/example', 22 | }, 23 | headers: { 24 | 'x-rate-limit-limit': '900', 25 | 'x-rate-limit-reset': Math.round(Date.now() / 1000) + 900 26 | } 27 | }; 28 | 29 | const errors = [ 30 | {statusCode: 400, details: {errorClass: AuthenticationError, message: 'test error (HTTP status: 400, Twitter code: 1337)'}}, 31 | {statusCode: 401, details: {errorClass: AuthenticationError, message: 'test error (HTTP status: 401, Twitter code: 1337)'}}, 32 | {statusCode: 401, details: {errorClass: WebhookURIError, message: 'test error (HTTP status: 401, Twitter code: 1337)'}}, 33 | {statusCode: 401, details: {errorClass: TooManySubscriptionsError, message: 'test error (HTTP status: 401, Twitter code: 1337)'}}, 34 | {statusCode: 403, details: {errorClass: AuthenticationError, message: 'test error (HTTP status: 403, Twitter code: 1337)'}}, 35 | {statusCode: 403, details: {errorClass: BearerTokenError, message: 'test error (HTTP status: 403, Twitter code: 1337)'}}, 36 | {statusCode: 429, details: {errorClass: RateLimitError, message: 'You exceeded the rate limit for /example (900 requests available, 0 remaining). Wait 15 minutes before trying again.'}}, 37 | {statusCode: 503, details: {errorClass: TwitterError, message: 'test error (HTTP status: 503, Twitter code: 1337)'}}, 38 | {statusCode: 503, details: {errorClass: UserSubscriptionError, message: 'test error (HTTP status: 503, Twitter code: 1337)'}}, 39 | ]; 40 | 41 | for (error of errors) { 42 | response.statusCode = error.statusCode; 43 | assert.throws(() => { 44 | throw new error.details.errorClass(response); 45 | }, 46 | { 47 | name: error.details.errorClass.name, 48 | message: error.details.message, 49 | }); 50 | } -------------------------------------------------------------------------------- /test/oauth.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const oauthInstance = require('../oauth'); 3 | const URL = require('url').URL; 4 | 5 | const oAuthConfig = { 6 | oauth: { 7 | consumer_key: 'consumer_key', 8 | consymer_secret: 'consumer_secret', 9 | token: 'test_user_token', 10 | token_secret: 'test_user_token_secret', 11 | }, 12 | }; 13 | 14 | const signatures = { 15 | baseUrl: 'OAuth oauth_consumer_key="consumer_key", oauth_nonce="GXjhffMbAMz2qblDzzgYbP4ZkfPp7RGmhry5Upatw", oauth_signature="RGpMnOI1WxKDZtgORbi32uc8P%2BY%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1583967563", oauth_token="test_user_token", oauth_version="1.0"', 16 | urlWithParams: 'OAuth oauth_consumer_key="consumer_key", oauth_nonce="xaCqEY8Ed9vnfFvZJsu8AjSF1", oauth_signature="LYT0mRAq62MXsnxsTOppEtViTUs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1583967750", oauth_token="test_user_token", oauth_version="1.0"', 17 | }; 18 | 19 | const baseUrl = new URL('https://api.twitter.com/1.1/account/verify_credentials.json') 20 | oauthInstance.setNonceFn(() => 'GXjhffMbAMz2qblDzzgYbP4ZkfPp7RGmhry5Upatw'); 21 | oauthInstance.setTimestampFn(() => '1583967563'); 22 | assert.throws(() => { 23 | // non-object and non string body throws TypeError 24 | oauthInstance.oauth(baseUrl, 'GET', oAuthConfig, () => {}); 25 | }, { 26 | name: 'TypeError', 27 | }); 28 | assert.equal(oauthInstance.oauth(baseUrl, 'GET', oAuthConfig), signatures.baseUrl); 29 | assert.equal(oauthInstance.oauth(baseUrl, 'GET', oAuthConfig, {}), signatures.baseUrl); 30 | assert.equal(oauthInstance.oauth(baseUrl, 'GET', oAuthConfig, ''), signatures.baseUrl); 31 | 32 | const urlWithParams = new URL('https://api.twitter.com/1.1/account/verify_credentials.json') 33 | urlWithParams.searchParams.append('param_test', '1'); 34 | urlWithParams.searchParams.append('example', '1'); 35 | oauthInstance.setNonceFn(() => 'xaCqEY8Ed9vnfFvZJsu8AjSF1'); 36 | oauthInstance.setTimestampFn(() => '1583967750'); 37 | assert.equal(oauthInstance.oauth(urlWithParams, 'GET', oAuthConfig), signatures.urlWithParams); 38 | 39 | const urlWithBodyParams = new URL('https://api.twitter.com/1.1/account/verify_credentials.json'); 40 | const bodyParams = { 41 | param_test: '1', 42 | example: '1' 43 | }; 44 | 45 | assert.equal(oauthInstance.oauth(urlWithParams, 'GET', oAuthConfig, bodyParams), signatures.urlWithParams); 46 | const bodyParamsString = 'param_test=1&example=1'; 47 | assert.equal(oauthInstance.oauth(urlWithParams, 'GET', oAuthConfig, bodyParamsString), signatures.urlWithParams); -------------------------------------------------------------------------------- /test/signature.js: -------------------------------------------------------------------------------- 1 | const { validateSignature } = require('../'); 2 | const assert = require('assert'); 3 | const oauth = { 4 | consumer_key: 'test', 5 | consumer_secret: 'test', 6 | token: 'test', 7 | token_secret: 'test', 8 | }; 9 | 10 | const fixtures = [ 11 | {body: 'test=1&other_test=2', header: {'x-twitter-webhooks-signature': 'sha256=25Cu3iwbbiqBTwQRzcKJZwisjf736V2Q8UaTlkfLSoc='}}, 12 | {body: '{"test_number":1,"test_string":"two"}', header: {'x-twitter-webhooks-signature': 'sha256=YWHphPn/JFq43jkF0y4/w8R/SelmLjvpunhVFY8JhlI='}}, 13 | ] 14 | 15 | for (const {body, header} of fixtures) { 16 | assert.ok(validateSignature(header, oauth, body)); 17 | } 18 | --------------------------------------------------------------------------------