├── .eslintignore ├── test ├── test.gif ├── stream.test.js └── twitter.test.js ├── .husky └── pre-commit ├── .travis.yml ├── .gitignore ├── .env.example ├── .idea └── jsLibraryMappings.xml ├── tsconfig.json ├── .vscode └── launch.json ├── .eslintrc.js ├── stream.js ├── .github └── workflows │ └── nodejs.yml ├── LICENSE ├── CHANGELOG.md ├── package.json ├── index.d.ts ├── twitter.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | index.d.ts -------------------------------------------------------------------------------- /test/test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draftbit/twitter-lite/HEAD/test/test.gif -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "node" 5 | 6 | sudo: false 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.log 4 | dist 5 | package-lock.json 6 | .env 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TWITTER_CONSUMER_KEY= 2 | TWITTER_CONSUMER_SECRET= 3 | ACCESS_TOKEN_KEY= 4 | ACCESS_TOKEN_SECRET= -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES6", 5 | "lib": [ 6 | "es6", 7 | "dom" 8 | ], 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "noEmit": true, 14 | "forceConsistentCasingInFileNames": true 15 | }, 16 | "files": [ 17 | "index.d.ts" 18 | ] 19 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "vscode-jest-tests", 10 | "request": "launch", 11 | "args": ["--runInBand"], 12 | "cwd": "${workspaceFolder}", 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 16 | "skipFiles": ["/**"] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | 'jest/globals': true, // describe, test, expect 7 | }, 8 | extends: 'eslint:recommended', 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | sourceType: 'module', 12 | }, 13 | plugins: [ 14 | 'jest', 15 | ], 16 | rules: { 17 | 'max-len': ['warn', { 18 | code: 128, // for GitHub 19 | ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true, ignoreRegExpLiterals: true, 20 | }], 21 | indent: ['error', 2], 22 | semi: ['error', 'always'], 23 | quotes: ['warn', 'single', { avoidEscape: true }], 24 | 'comma-dangle': ['warn', 'always-multiline'], 25 | 'object-curly-spacing': ['error', 'always'], 26 | 'no-multi-spaces': ['error', { ignoreEOLComments: true }], 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /stream.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const END = '\r\n'; 3 | const END_LENGTH = 2; 4 | 5 | class Stream extends EventEmitter { 6 | constructor() { 7 | super(); 8 | this.buffer = ''; 9 | } 10 | 11 | parse(buffer) { 12 | this.buffer += buffer.toString('utf8'); 13 | let index; 14 | let json; 15 | 16 | while ((index = this.buffer.indexOf(END)) > -1) { 17 | json = this.buffer.slice(0, index); 18 | this.buffer = this.buffer.slice(index + END_LENGTH); 19 | if (json.length > 0) { 20 | try { 21 | json = JSON.parse(json); 22 | this.emit(json.event || 'data', json); 23 | } catch (error) { 24 | error.source = json; 25 | this.emit('error', error); 26 | } 27 | } else { 28 | this.emit('ping'); 29 | } 30 | } 31 | } 32 | } 33 | 34 | module.exports = Stream; 35 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: ['12', '14'] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: 'yarn' 20 | - name: Verify lockfile and install dependencies 21 | run: yarn install --frozen-lockfile 22 | - name: lint 23 | run: yarn lint 24 | - name: test 25 | env: # twitter keys to run tests 26 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 27 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 28 | ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} 29 | ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 30 | if: env.TWITTER_CONSUMER_KEY 31 | run: yarn test 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2018 Peter Piekarczyk 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.9.3, 2019-Mar-25 2 | 3 | - Return `_headers` in stream creation errors as well 4 | 5 | # v0.9.1, 2019-Jan-16 6 | 7 | - Fix encoding of special characters in direct messages ([#38](https://github.com/draftbit/twitter-lite/issues/38)) 8 | 9 | # v0.9, 2019-Jan-06 10 | 11 | ## Breaking changes 12 | 13 | - `.post()` now only takes two parameters: the resource and the body/parameters. If you were previously passing `null` for the body, just delete that, and the next parameter will become the body. 14 | 15 | ## Changes 16 | 17 | - Properly encode and sign POST parameters/body depending on whether the endpoint takes [`application/json`](https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event) or [`application/x-www-form-urlencoded`](https://developer.twitter.com/en/docs/basics/authentication/guides/creating-a-signature) 18 | - Support empty responses (e.g. those returned by [`direct_messages/indicate_typing`](https://developer.twitter.com/en/docs/direct-messages/typing-indicator-and-read-receipts/api-reference/new-typing-indicator)) (fix [#35](https://github.com/draftbit/twitter-lite/issues/35)) 19 | 20 | # v0.8, 2018-Dec-13 21 | 22 | - Encode special characters in the POST body (fix [#36](https://github.com/draftbit/twitter-lite/issues/36)) 23 | 24 | # v0.7, 2018-Jul-26 25 | 26 | ## Breaking changes 27 | 28 | - Given that [developers expect promises to reject when they don't return the requested data](https://github.com/ttezel/twit/issues/256), `.get` and `.post` now reject instead of silently returning API errors as an array under the `errors` key of the response object. 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-lite", 3 | "version": "1.1.0", 4 | "description": "Tiny, full-featured client/server REST/stream library for the Twitter API", 5 | "source": "twitter.js", 6 | "main": "dist/twitter.js", 7 | "module": "dist/twitter.m.js", 8 | "types": "index.d.ts", 9 | "files": [ 10 | "dist", 11 | "index.d.ts" 12 | ], 13 | "repository": "draftbit/twitter-lite", 14 | "homepage": "https://github.com/draftbit/twitter-lite", 15 | "author": "Peter Piekarczyk ", 16 | "contributors": [ 17 | "Dan Dascalescu (https://github.com/dandv)" 18 | ], 19 | "license": "MIT", 20 | "keywords": [ 21 | "twitter", 22 | "rest", 23 | "api", 24 | "twitter api", 25 | "node-twitter", 26 | "twitter oauth", 27 | "twitter rest", 28 | "twitter stream" 29 | ], 30 | "dependencies": { 31 | "cross-fetch": "^3.0.0", 32 | "oauth-1.0a": "^2.2.4" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^25.2.1", 36 | "@types/node": "^14.17.9", 37 | "bundlesize": "^0.18.0", 38 | "dotenv": "^10.0.0", 39 | "eslint": "^6.8.0", 40 | "eslint-plugin-jest": "^23.8.2", 41 | "flow-bin": "^0.123.0", 42 | "husky": "^7.0.1", 43 | "jest": "^25.5.0", 44 | "microbundle": "^0.13.3", 45 | "typescript": "^4.3.5" 46 | }, 47 | "scripts": { 48 | "lint": "eslint . && tsc index.d.ts", 49 | "fix": "eslint --fix .", 50 | "build": "microbundle {stream,twitter}.js && bundlesize", 51 | "test": "jest --detectOpenHandles", 52 | "release": "npm run -s build && npm run lint && npm test && git tag $npm_package_version && git push && git push --tags && npm publish", 53 | "prepare": "husky install" 54 | }, 55 | "jest": { 56 | "testEnvironment": "node" 57 | }, 58 | "bundlesize": [ 59 | { 60 | "path": "dist/**.js", 61 | "maxSize": "3 kB" 62 | }, 63 | { 64 | "path": "index.d.ts", 65 | "maxSize": "3 kB" 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Typings for twitter-lite 3 | * 4 | * @version 0.10-1.0 5 | * @author Floris de Bijl <@fdebijl> 6 | * 7 | * @example 8 | * const Twitter = require('twitter-lite') 9 | * 10 | * const twitter = new Twitter({ 11 | * consumer_key: 'XYZ', 12 | * consumer_secret: 'XYZ', 13 | * access_token_key: 'XYZ', 14 | * access_token_secret: 'XYZ' 15 | * }); 16 | * 17 | * @example 18 | * // Enable esModuleInterop in your tsconfig to import typings 19 | * import Twitter, { TwitterOptions } from 'twitter-lite' 20 | * 21 | * const config: TwitterOptions = { 22 | * consumer_key: 'XYZ', 23 | * consumer_secret: 'XYZ', 24 | * access_token_key: 'XYZ', 25 | * access_token_secret: 'XYZ' 26 | * }; 27 | * 28 | * const twitter = new Twitter(config); 29 | */ 30 | 31 | /// 32 | import { EventEmitter } from 'events'; 33 | import * as OAuth from 'oauth-1.0a'; 34 | 35 | export default class Twitter { 36 | private authType: AuthType; 37 | private url: string; 38 | private oauth: string; 39 | private config: TwitterOptions; 40 | private client: OAuth; 41 | private token: KeySecret; 42 | 43 | constructor(options: TwitterOptions); 44 | 45 | /** 46 | * Parse the JSON from a Response object and add the Headers under `_headers` 47 | */ 48 | private static _handleResponse(response: Response): Promise; 49 | 50 | getBearerToken(): Promise; 51 | 52 | /** The value you specify here will be used as the URL a user is redirected to should they approve your application's access to their account. Set this to oob for out-of-band pin mode. */ 53 | getRequestToken(twitterCallbackUrl: string | 'oob'): Promise; 54 | 55 | getAccessToken(options: AccessTokenOptions): Promise; 56 | 57 | /** 58 | * Construct the data and headers for an authenticated HTTP request to the Twitter API 59 | * @param {'GET | 'POST' | 'PUT'} method 60 | * @param {string} resource - the API endpoint 61 | */ 62 | private _makeRequest( 63 | method: 'GET' | 'POST' | 'PUT', 64 | resource: string, 65 | parameters: object 66 | ): { 67 | requestData: { url: string; method: string }; 68 | headers: { Authorization: string } | OAuth.Header; 69 | }; 70 | 71 | /** 72 | * Send a GET request 73 | * @type {T = any} Expected type for the response from this request, generally `object` or `array`. 74 | * @param {string} resource - endpoint, e.g. `followers/ids` 75 | * @param {object} [parameters] - optional parameters 76 | * @returns {Promise} Promise resolving to the response from the Twitter API. 77 | * The `_header` property will be set to the Response headers (useful for checking rate limits) 78 | */ 79 | public get(resource: string, parameters?: object): Promise; 80 | 81 | /** 82 | * Send a POST request 83 | * @type {T = any} Expected type for the response from this request, generally `object` or `array`. 84 | * @param {string} resource - endpoint, e.g. `users/lookup` 85 | * @param {object} body - POST parameters object. 86 | * Will be encoded appropriately (JSON or urlencoded) based on the resource 87 | * @returns {Promise} Promise resolving to the response from the Twitter API. 88 | * The `_header` property will be set to the Response headers (useful for checking rate limits) 89 | */ 90 | public post(resource: string, body: object): Promise; 91 | 92 | /** 93 | * Send a PUT request 94 | * @type {T = any} Expected type for the response from this request, generally `object` or `array`. 95 | * @param {string} resource - endpoint e.g. `direct_messages/welcome_messages/update` 96 | * @param {object} parameters - required or optional query parameters 97 | * @param {object} body - PUT request body 98 | * @returns {Promise} Promise resolving to the response from the Twitter API. 99 | */ 100 | public put( 101 | resource: string, 102 | parameters: object, 103 | body: object 104 | ): Promise; 105 | 106 | /** 107 | * Open a stream to a specified endpoint 108 | * 109 | * @param {string} resource - endpoint, e.g. `statuses/filter` 110 | * @param {object} parameters 111 | * @returns {Stream} 112 | */ 113 | public stream(resource: string, parameters: object): Stream; 114 | } 115 | 116 | /* In reality snowflakes are BigInts. Once BigInt is supported by browsers and Node per default, we could adjust this type. 117 | Currently Twitter themselves convert it to strings for the API though, so this change will come some time in the far future. */ 118 | type snowflake = string; 119 | 120 | interface TwitterOptions { 121 | /** "api" is the default (change for other subdomains) */ 122 | subdomain?: string; 123 | /** version "1.1" is the default (change for other subdomains) */ 124 | version?: string; 125 | /** version "2" does not use .json for endpoints, defaults to true */ 126 | extension?: boolean; 127 | /** consumer key from Twitter. */ 128 | consumer_key: string; 129 | /** consumer secret from Twitter */ 130 | consumer_secret: string; 131 | /** access token key from your User (oauth_token) */ 132 | access_token_key?: OauthToken; 133 | /** access token secret from your User (oauth_token_secret) */ 134 | access_token_secret?: OauthTokenSecret; 135 | /** bearer token */ 136 | bearer_token?: string; 137 | } 138 | 139 | type OauthToken = string; 140 | type OauthTokenSecret = string; 141 | type AuthType = 'App' | 'User'; 142 | 143 | interface KeySecret { 144 | key: string; 145 | secret: string; 146 | } 147 | 148 | interface AccessTokenOptions { 149 | /** If using the OAuth web-flow, set these parameters to the values returned in the callback URL. If you are using out-of-band OAuth, set the value of oauth_verifier to the pin-code. 150 | * The oauth_token here must be the same as the oauth_token returned in the request_token step.*/ 151 | oauth_verifier: string | number; 152 | oauth_token: string; 153 | } 154 | 155 | interface BearerResponse { 156 | token_type: 'bearer'; 157 | access_token: string; 158 | } 159 | 160 | type TokenResponse = 161 | | { 162 | oauth_token: OauthToken; 163 | oauth_token_secret: OauthTokenSecret; 164 | oauth_callback_confirmed: 'true'; 165 | } 166 | | { oauth_callback_confirmed: 'false' }; 167 | 168 | interface AccessTokenResponse { 169 | oauth_token: string; 170 | oauth_token_secret: string; 171 | user_id: snowflake; 172 | screen_name: string; 173 | } 174 | 175 | declare class Stream extends EventEmitter { 176 | constructor(); 177 | 178 | parse(buffer: Buffer): void; 179 | destroy(): void; 180 | } 181 | -------------------------------------------------------------------------------- /test/stream.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | require('dotenv').config(); 4 | const Stream = require('../stream'); 5 | const Twitter = require('../twitter'); 6 | 7 | const { 8 | TWITTER_CONSUMER_KEY, 9 | TWITTER_CONSUMER_SECRET, 10 | ACCESS_TOKEN, 11 | ACCESS_TOKEN_SECRET, 12 | } = process.env; 13 | 14 | function newClient(subdomain = 'api') { 15 | return new Twitter({ 16 | subdomain, 17 | consumer_key: TWITTER_CONSUMER_KEY, 18 | consumer_secret: TWITTER_CONSUMER_SECRET, 19 | access_token_key: ACCESS_TOKEN, 20 | access_token_secret: ACCESS_TOKEN_SECRET, 21 | }); 22 | } 23 | 24 | function sleep(ms) { 25 | return new Promise(resolve => setTimeout(resolve, ms)); 26 | } 27 | 28 | // https://30secondsofcode.org/object#pick 29 | function pick(obj, arr) { 30 | return arr.reduce( 31 | (acc, curr) => (curr in obj && (acc[curr] = obj[curr]), acc), 32 | {}, 33 | ); 34 | } 35 | 36 | const client = newClient(); 37 | let stream; 38 | 39 | // Prolific users from https://socialblade.com/twitter/top/100/tweets that are still active. 40 | // Get with $('div.table-cell a').map(function () { return this.href }) then use 41 | // users/lookup to convert to IDs. 42 | const chattyUserIds = [ 43 | '63299591', '115639376', '4823945834', '2669983818', '6529402', '362413805', 44 | '450395397', '15007299', '132355708', '561669474', '2213312341', '2050001283', 45 | '89142182', '2316574981', '133684052', '255409050', '15518000', '124172948', 46 | '225647847', '3012764258', '382430644', '42832810', '2233720891', '290395312', 47 | '50706690', '1388673048', '414306138', '155409802', '21976463', '1179710990', 48 | '130426181', '171299971', '32453798', '22279680', '22274998', '59804598', 49 | '3048544857', '17872077', '85741735', '3032932864', '120421476', '473656787', 50 | '876302191', '717628618906570752', '15518784', '152641509', '5950272', 51 | '416383737', '2569759392', '165796189', '1680484418', '108192135', '3007312628', 52 | '32771325', '764410142679035904', '19272300', '829411574', '68956490', 53 | '2836271637', '392599269', '1145130336', '52236744', '243133079', '104120518', 54 | '51684249', '18057450', '1027850761', '1868107663', '213165296', '15503908', 55 | '1346933186', '2857426909', '2814731582', '453780255', '3027662932', '23719043', 56 | '486288760', '121190725', '2942062137', '19286574', '21033096', '271986064', 57 | ]; 58 | 59 | const trackKeywords = [ 60 | 'the,to,and,in,you,for,my,at,me', 61 | 'i,a,is,it,of,on,that,with,do', 62 | ]; 63 | // Passed when run standalone: 20 * 20s; 20 * 15s failed. All failures happened before trying to create the 3rd stream. 64 | const N = 10; 65 | 66 | function switchStream({ count, waitBetweenStreams, done, errorHandler }) { 67 | setTimeout(() => { 68 | stream = client.stream('statuses/filter', { 69 | track: trackKeywords[count % 2], 70 | }); 71 | stream 72 | .on('data', tweet => { 73 | console.log(`Tweet from stream #${count}: ${tweet.text}`); 74 | stream.destroy(); // process.nextTick(() => stream.destroy()); // works too 75 | if (count === N) 76 | done(); 77 | else 78 | switchStream({ 79 | count: count + 1, 80 | waitBetweenStreams, 81 | done, 82 | errorHandler, 83 | }); 84 | }) 85 | .on('error', error => { 86 | error.count = count; 87 | errorHandler(error); 88 | }); 89 | }, waitBetweenStreams); 90 | } 91 | 92 | it('should default export to be a function', () => { 93 | expect(new Stream()).toBeInstanceOf(Stream); 94 | }); 95 | 96 | describe('streams', () => { 97 | beforeEach(() => { 98 | console.log(new Date().toISOString(), 'Waiting 60s...'); 99 | return sleep(60 * 1000); 100 | //console.log(new Date().toISOString(), 'Done waiting.'); 101 | }, 61 * 1000); 102 | 103 | const waitLongEnough = 30 * 1000; // 20s was enough on 2019-03-21, but not now... 104 | it('should reuse stream N times', done => { 105 | console.log(new Date().toISOString(), 'Starting reuse N times test...'); 106 | // 'Too many connections – your app established too many simultaneous connections to the data stream. When this occurs, Twitter will wait 1 minute, and then disconnect the most recently established connection if the limit is still being exceeded.' -- https://developer.twitter.com/en/docs/tutorials/consuming-streaming-data.html 107 | switchStream({ 108 | count: 1, 109 | waitBetweenStreams: waitLongEnough, 110 | done, 111 | errorHandler: error => { 112 | console.log('Error switching stream', error); 113 | const fields = pick(error, ['status', 'statusText', 'count']); 114 | expect(fields).toMatchObject({ 115 | status: 200, 116 | statusText: 'OK', 117 | count: N, 118 | }); // force fail 119 | done(); 120 | }, 121 | }); 122 | }, (N + 1) * waitLongEnough * 1000); 123 | 124 | // This test exceeds Twitter's stream rate limit, so it must be the last 125 | it('should fail when switching from one stream to another too fast', done => { 126 | console.log(new Date().toISOString(), 'Starting stream reuse withOUT wait, which will FAIL...'); 127 | switchStream({ 128 | count: 1, 129 | waitBetweenStreams: 1000, 130 | errorHandler: error => { 131 | const fields = pick(error, [ 132 | 'status', 133 | 'statusText', 134 | 'count', 135 | 'message', 136 | 'source', 137 | ]); 138 | if (fields.status) 139 | expect(fields).toMatchObject({ 140 | status: 420, 141 | statusText: 'Enhance Your Calm', 142 | count: expect.any(Number), 143 | }); 144 | else { 145 | expect(fields).toMatchObject({ 146 | source: 'Exceeded connection limit for user', 147 | message: 'Unexpected token E in JSON at position 0', 148 | }); 149 | done(); 150 | } 151 | }, 152 | }); 153 | }); 154 | 155 | // This test needs to be last because it appears that Twitter doesn't register 156 | // the stream being destroyed. If this test precedes the 'should reuse stream N times' 157 | // test, the latter will fail, even though on its own, it can reuse the stream 20+ times. 158 | it('should filter realtime tweets from up to 5000 users', done => { 159 | console.log(new Date().toISOString(), 'Starting 5000 users test...'); 160 | // https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter 161 | stream = client.stream('statuses/filter', { 162 | follow: [ 163 | // First pass a ton of times an account that doesn't tweet often, to stress-test the POST body 164 | ...Array(4900).fill('15008676'), // @dandv 165 | ...chattyUserIds, 166 | ], 167 | }); 168 | 169 | stream 170 | .on('data', () => { 171 | // Within seconds, one of those prolific accounts will tweet something. 172 | // Destroy the stream, or else the script will not terminate. 173 | stream.destroy(); 174 | done(); 175 | console.log(new Date().toISOString(), 'Stream to follow users was allegedly destroyed'); 176 | }) 177 | .on('error', error => { 178 | // Force fail 179 | expect(error).toMatchObject({ 180 | status: 200, 181 | }); 182 | }); 183 | }, 60 * 1000); 184 | 185 | }); 186 | -------------------------------------------------------------------------------- /twitter.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const OAuth = require('oauth-1.0a'); 3 | const Fetch = require('cross-fetch'); 4 | const querystring = require('querystring'); 5 | const Stream = require('./stream'); 6 | 7 | const getUrl = (subdomain, endpoint = '1.1') => 8 | `https://${subdomain}.twitter.com/${endpoint}`; 9 | 10 | const createOauthClient = ({ key, secret }) => { 11 | const client = OAuth({ 12 | consumer: { key, secret }, 13 | signature_method: 'HMAC-SHA1', 14 | hash_function(baseString, key) { 15 | return crypto 16 | .createHmac('sha1', key) 17 | .update(baseString) 18 | .digest('base64'); 19 | }, 20 | }); 21 | 22 | return client; 23 | }; 24 | 25 | const defaults = { 26 | subdomain: 'api', 27 | consumer_key: null, 28 | consumer_secret: null, 29 | access_token_key: null, 30 | access_token_secret: null, 31 | bearer_token: null, 32 | version: '1.1', 33 | extension: true, 34 | }; 35 | 36 | // Twitter expects POST body parameters to be URL-encoded: https://developer.twitter.com/en/docs/basics/authentication/guides/creating-a-signature 37 | // However, some endpoints expect a JSON payload - https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event 38 | // It appears that JSON payloads don't need to be included in the signature, 39 | // because sending DMs works without signing the POST body 40 | const JSON_ENDPOINTS = [ 41 | 'direct_messages/events/new', 42 | 'direct_messages/welcome_messages/new', 43 | 'direct_messages/welcome_messages/rules/new', 44 | 'media/metadata/create', 45 | 'collections/entries/curate', 46 | ]; 47 | 48 | const baseHeaders = { 49 | 'Content-Type': 'application/json', 50 | Accept: 'application/json', 51 | }; 52 | 53 | function percentEncode(string) { 54 | // From OAuth.prototype.percentEncode 55 | return string 56 | .replace(/!/g, '%21') 57 | .replace(/\*/g, '%2A') 58 | .replace(/'/g, '%27') 59 | .replace(/\(/g, '%28') 60 | .replace(/\)/g, '%29'); 61 | } 62 | 63 | class Twitter { 64 | constructor(options) { 65 | const config = Object.assign({}, defaults, options); 66 | this.authType = config.bearer_token ? 'App' : 'User'; 67 | this.client = createOauthClient({ 68 | key: config.consumer_key, 69 | secret: config.consumer_secret, 70 | }); 71 | 72 | this.token = { 73 | key: config.access_token_key, 74 | secret: config.access_token_secret, 75 | }; 76 | 77 | this.url = getUrl(config.subdomain, config.version); 78 | this.oauth = getUrl(config.subdomain, 'oauth'); 79 | this.config = config; 80 | } 81 | 82 | /** 83 | * Parse the JSON from a Response object and add the Headers under `_headers` 84 | * @param {Response} response - the Response object returned by Fetch 85 | * @return {Promise} 86 | * @private 87 | */ 88 | static async _handleResponse(response) { 89 | const headers = response.headers; // TODO: see #44 90 | if (response.ok) { 91 | // Return empty response on 204 "No content", or Content-Length=0 92 | if (response.status === 204 || response.headers.get('content-length') === '0') 93 | return { 94 | _headers: headers, 95 | }; 96 | // Otherwise, parse JSON response 97 | return response.json().then(res => { 98 | res._headers = headers; // TODO: this creates an array-like object when it adds _headers to an array response 99 | return res; 100 | }); 101 | } else { 102 | throw { 103 | _headers: headers, 104 | ...await response.json(), 105 | }; 106 | } 107 | } 108 | 109 | /** 110 | * Resolve the TEXT parsed from the successful response or reject the JSON from the error 111 | * @param {Response} response - the Response object returned by Fetch 112 | * @return {Promise} 113 | * @throws {Promise} 114 | * @private 115 | */ 116 | static async _handleResponseTextOrJson(response) { 117 | let body = await response.text(); 118 | if (response.ok) { 119 | return querystring.parse(body); 120 | } else { 121 | let error; 122 | try { 123 | // convert to object if it is a json 124 | error = JSON.parse(body); 125 | } catch (e) { 126 | // it is not a json 127 | error = body; 128 | } 129 | return Promise.reject(error); 130 | } 131 | } 132 | 133 | async getBearerToken() { 134 | const headers = { 135 | Authorization: 136 | 'Basic ' + 137 | Buffer.from( 138 | this.config.consumer_key + ':' + this.config.consumer_secret, 139 | ).toString('base64'), 140 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 141 | }; 142 | 143 | const results = await Fetch('https://api.twitter.com/oauth2/token', { 144 | method: 'POST', 145 | body: 'grant_type=client_credentials', 146 | headers, 147 | }).then(Twitter._handleResponse); 148 | 149 | return results; 150 | } 151 | 152 | async getRequestToken(twitterCallbackUrl) { 153 | const requestData = { 154 | url: `${this.oauth}/request_token`, 155 | method: 'POST', 156 | }; 157 | 158 | let parameters = {}; 159 | if (twitterCallbackUrl) parameters = { oauth_callback: twitterCallbackUrl }; 160 | if (parameters) requestData.url += '?' + querystring.stringify(parameters); 161 | 162 | const headers = this.client.toHeader( 163 | this.client.authorize(requestData, {}), 164 | ); 165 | 166 | const results = await Fetch(requestData.url, { 167 | method: 'POST', 168 | headers: Object.assign({}, baseHeaders, headers), 169 | }) 170 | .then(Twitter._handleResponseTextOrJson); 171 | 172 | return results; 173 | } 174 | 175 | async getAccessToken(options) { 176 | const requestData = { 177 | url: `${this.oauth}/access_token`, 178 | method: 'POST', 179 | }; 180 | 181 | let parameters = { oauth_verifier: options.oauth_verifier, oauth_token: options.oauth_token }; 182 | if (parameters.oauth_verifier && parameters.oauth_token) requestData.url += '?' + querystring.stringify(parameters); 183 | 184 | const headers = this.client.toHeader( this.client.authorize(requestData) ); 185 | 186 | const results = await Fetch(requestData.url, { 187 | method: 'POST', 188 | headers: Object.assign({}, baseHeaders, headers), 189 | }) 190 | .then(Twitter._handleResponseTextOrJson); 191 | 192 | return results; 193 | } 194 | 195 | /** 196 | * Construct the data and headers for an authenticated HTTP request to the Twitter API 197 | * @param {string} method - 'GET' or 'POST' 198 | * @param {string} resource - the API endpoint 199 | * @param {object} parameters 200 | * @return {{requestData: {url: string, method: string}, headers: ({Authorization: string}|OAuth.Header)}} 201 | * @private 202 | */ 203 | _makeRequest(method, resource, parameters) { 204 | const requestData = { 205 | url: `${this.url}/${resource}${this.config.extension ? '.json' : ''}`, 206 | method, 207 | }; 208 | if (parameters) 209 | if (method === 'POST') requestData.data = parameters; 210 | else requestData.url += '?' + querystring.stringify(parameters); 211 | 212 | let headers = {}; 213 | if (this.authType === 'User') { 214 | headers = this.client.toHeader( 215 | this.client.authorize(requestData, this.token), 216 | ); 217 | } else { 218 | headers = { 219 | Authorization: `Bearer ${this.config.bearer_token}`, 220 | }; 221 | } 222 | return { 223 | requestData, 224 | headers, 225 | }; 226 | } 227 | 228 | /** 229 | * Send a GET request 230 | * @param {string} resource - endpoint, e.g. `followers/ids` 231 | * @param {object} [parameters] - optional parameters 232 | * @returns {Promise} Promise resolving to the response from the Twitter API. 233 | * The `_header` property will be set to the Response headers (useful for checking rate limits) 234 | */ 235 | get(resource, parameters) { 236 | const { requestData, headers } = this._makeRequest( 237 | 'GET', 238 | resource, 239 | parameters, 240 | ); 241 | 242 | return Fetch(requestData.url, { headers }) 243 | .then(Twitter._handleResponse); 244 | } 245 | 246 | /** 247 | * Send a POST request 248 | * @param {string} resource - endpoint, e.g. `users/lookup` 249 | * @param {object} body - POST parameters object. 250 | * Will be encoded appropriately (JSON or urlencoded) based on the resource 251 | * @returns {Promise} Promise resolving to the response from the Twitter API. 252 | * The `_header` property will be set to the Response headers (useful for checking rate limits) 253 | */ 254 | post(resource, body) { 255 | const { requestData, headers } = this._makeRequest( 256 | 'POST', 257 | resource, 258 | JSON_ENDPOINTS.includes(resource) ? null : body, // don't sign JSON bodies; only parameters 259 | ); 260 | 261 | const postHeaders = Object.assign({}, baseHeaders, headers); 262 | if (JSON_ENDPOINTS.includes(resource)) { 263 | body = JSON.stringify(body); 264 | } else { 265 | body = percentEncode(querystring.stringify(body)); 266 | postHeaders['Content-Type'] = 'application/x-www-form-urlencoded'; 267 | } 268 | 269 | return Fetch(requestData.url, { 270 | method: 'POST', 271 | headers: postHeaders, 272 | body, 273 | }) 274 | .then(Twitter._handleResponse); 275 | } 276 | 277 | /** 278 | * Send a PUT request 279 | * @param {string} resource - endpoint e.g. `direct_messages/welcome_messages/update` 280 | * @param {object} parameters - required or optional query parameters 281 | * @param {object} body - PUT request body 282 | * @returns {Promise} Promise resolving to the response from the Twitter API. 283 | */ 284 | put(resource, parameters, body) { 285 | const { requestData, headers } = this._makeRequest( 286 | 'PUT', 287 | resource, 288 | parameters, 289 | ); 290 | 291 | const putHeaders = Object.assign({}, baseHeaders, headers); 292 | body = JSON.stringify(body); 293 | 294 | return Fetch(requestData.url, { 295 | method: 'PUT', 296 | headers: putHeaders, 297 | body, 298 | }) 299 | .then(Twitter._handleResponse); 300 | } 301 | 302 | /** 303 | * 304 | * @param {string} resource - endpoint, e.g. `statuses/filter` 305 | * @param {object} parameters 306 | * @returns {Stream} 307 | */ 308 | stream(resource, parameters) { 309 | if (this.authType !== 'User') 310 | throw new Error('Streams require user context authentication'); 311 | 312 | const stream = new Stream(); 313 | 314 | // POST the request, in order to accommodate long parameter lists, e.g. 315 | // up to 5000 ids for statuses/filter - https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter 316 | const requestData = { 317 | url: `${getUrl('stream')}/${resource}${this.config.extension ? '.json' : ''}`, 318 | method: 'POST', 319 | }; 320 | if (parameters) requestData.data = parameters; 321 | 322 | const headers = this.client.toHeader( 323 | this.client.authorize(requestData, this.token), 324 | ); 325 | 326 | const request = Fetch(requestData.url, { 327 | method: 'POST', 328 | headers: { 329 | ...headers, 330 | 'Content-Type': 'application/x-www-form-urlencoded', 331 | }, 332 | body: percentEncode(querystring.stringify(parameters)), 333 | }); 334 | 335 | request 336 | .then(response => { 337 | stream.destroy = this.stream.destroy = () => response.body.destroy(); 338 | 339 | if (response.ok) { 340 | stream.emit('start', response); 341 | } else { 342 | response._headers = response.headers; // TODO: see #44 - could omit the line 343 | stream.emit('error', response); 344 | } 345 | 346 | response.body 347 | .on('data', chunk => stream.parse(chunk)) 348 | .on('error', error => stream.emit('error', error)) // no point in adding the original response headers 349 | .on('end', () => stream.emit('end', response)); 350 | }) 351 | .catch(error => stream.emit('error', error)); 352 | 353 | return stream; 354 | } 355 | } 356 | 357 | module.exports = Twitter; 358 | -------------------------------------------------------------------------------- /test/twitter.test.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const Twitter = require('../twitter'); 5 | 6 | const { 7 | TWITTER_CONSUMER_KEY, 8 | TWITTER_CONSUMER_SECRET, 9 | ACCESS_TOKEN, 10 | ACCESS_TOKEN_SECRET, 11 | } = process.env; 12 | 13 | const STRING_WITH_SPECIAL_CHARS = "`!@#$%^&*()-_=+[{]}\\|;:'\",<.>/? ✓"; 14 | const DIRECT_MESSAGE_RECIPIENT_ID = '1253003423055843328'; // https://twitter.com/twlitetest 15 | const TEST_IMAGE = fs.readFileSync(path.join(__dirname, 'test.gif')); 16 | 17 | function newClient(subdomain = 'api') { 18 | return new Twitter({ 19 | subdomain, 20 | consumer_key: TWITTER_CONSUMER_KEY, 21 | consumer_secret: TWITTER_CONSUMER_SECRET, 22 | access_token_key: ACCESS_TOKEN, 23 | access_token_secret: ACCESS_TOKEN_SECRET, 24 | }); 25 | } 26 | 27 | // Used when testing DMs to avoid getting flagged for abuse 28 | function randomString() { 29 | return Math.random().toString(36).substr(2, 11); 30 | } 31 | 32 | function htmlEscape(string) { 33 | return string 34 | .replace(/&/g, '&') 35 | .replace(//g, '>'); 37 | } 38 | 39 | describe('core', () => { 40 | it('should default export to be a function', () => { 41 | expect(new Twitter()).toBeInstanceOf(Twitter); 42 | }); 43 | 44 | it('should return the API URL', () => { 45 | expect(new Twitter().url).toEqual('https://api.twitter.com/1.1'); 46 | }); 47 | 48 | it('should return a stream API URL', () => { 49 | const options = { subdomain: 'stream' }; 50 | expect(new Twitter(options).url).toEqual('https://stream.twitter.com/1.1'); 51 | }); 52 | }); 53 | 54 | describe('auth', () => { 55 | it('should fail on invalid access_token_secret', async () => { 56 | const client = new Twitter({ 57 | subdomain: 'api', 58 | consumer_key: TWITTER_CONSUMER_KEY, 59 | consumer_secret: TWITTER_CONSUMER_SECRET, 60 | access_token_key: ACCESS_TOKEN, 61 | access_token_secret: 'xyz', 62 | }); 63 | 64 | expect.assertions(1); 65 | try { 66 | await client.get('account/verify_credentials'); 67 | } catch (e) { 68 | expect(e).toMatchObject({ 69 | errors: [{ code: 32, message: 'Could not authenticate you.' }], 70 | }); 71 | } 72 | }); 73 | 74 | it('should fail on invalid or expired token', async () => { 75 | const client = new Twitter({ 76 | subdomain: 'api', 77 | consumer_key: 'xyz', 78 | consumer_secret: 'xyz', 79 | access_token_key: 'xyz', 80 | access_token_secret: 'xyz', 81 | }); 82 | 83 | expect.assertions(1); 84 | try { 85 | await client.get('account/verify_credentials'); 86 | } catch (e) { 87 | expect(e).toMatchObject({ 88 | errors: [{ code: 89, message: 'Invalid or expired token.' }], 89 | }); 90 | } 91 | }); 92 | 93 | it('should verify credentials with correct tokens', async () => { 94 | const client = newClient(); 95 | 96 | const response = await client.get('account/verify_credentials'); 97 | expect(response).toHaveProperty('screen_name'); 98 | }); 99 | 100 | it('should use bearer token successfully', async () => { 101 | const user = new Twitter({ 102 | consumer_key: TWITTER_CONSUMER_KEY, 103 | consumer_secret: TWITTER_CONSUMER_SECRET, 104 | }); 105 | 106 | const response = await user.getBearerToken(); 107 | expect(response).toMatchObject({ 108 | token_type: 'bearer', 109 | }); 110 | const app = new Twitter({ 111 | bearer_token: response.access_token, 112 | }); 113 | const rateLimits = await app.get('application/rate_limit_status', { 114 | resources: 'statuses', 115 | }); 116 | // This rate limit is 75 for user auth and 300 for app auth 117 | expect( 118 | rateLimits.resources.statuses['/statuses/retweeters/ids'].limit, 119 | ).toEqual(300); 120 | }); 121 | }); 122 | 123 | describe('rate limits', () => { 124 | let client; 125 | beforeAll(() => (client = newClient())); 126 | 127 | it( 128 | 'should get rate limited', 129 | async () => { 130 | expect.assertions(2); // assume we were rate limited by a previous test and go straight to `catch` 131 | try { 132 | const response = await client.get('help/languages'); 133 | // Since this didn't throw, we'll be running 2 more assertions below 134 | expect.assertions(4); 135 | expect(response).toHaveProperty('0.code'); 136 | expect(response._headers.get('x-rate-limit-limit')).toEqual('15'); 137 | let remaining = response._headers.get('x-rate-limit-remaining'); 138 | while ( 139 | remaining-- >= -1 // force exceeding the rate limit 140 | ) 141 | await client.get('help/languages'); 142 | } catch (e) { 143 | expect(e.errors[0]).toHaveProperty('code', 88); // Rate limit exceeded 144 | expect(e._headers.get('x-rate-limit-remaining')).toEqual('0'); 145 | } 146 | }, 147 | 10 * 1000, 148 | ); 149 | }); 150 | 151 | describe('posting', () => { 152 | let client; 153 | beforeAll(() => (client = newClient())); 154 | 155 | it('should DM user, including special characters', async () => { 156 | const message = randomString(); // prevent overzealous abuse detection 157 | 158 | // POST with JSON body and no parameters per https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event 159 | const response = await client.post('direct_messages/events/new', { 160 | event: { 161 | type: 'message_create', 162 | message_create: { 163 | target: { 164 | recipient_id: DIRECT_MESSAGE_RECIPIENT_ID, 165 | }, 166 | message_data: { 167 | text: message + STRING_WITH_SPECIAL_CHARS, 168 | // https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event#message-data-object 169 | // says "URL encode as necessary", but applying encodeURIComponent results in verbatim %NN being sent 170 | }, 171 | }, 172 | }, 173 | }); 174 | expect(response).toMatchObject({ 175 | event: { 176 | type: 'message_create', 177 | id: expect.stringMatching(/^\d+$/), 178 | created_timestamp: expect.any(String), 179 | message_create: { 180 | message_data: { 181 | text: htmlEscape(message + STRING_WITH_SPECIAL_CHARS), 182 | }, 183 | }, 184 | }, 185 | }); 186 | }); 187 | 188 | it('should send typing indicator and parse empty response', async () => { 189 | // https://developer.twitter.com/en/docs/direct-messages/typing-indicator-and-read-receipts/api-reference/new-typing-indicator 190 | const response = await client.post('direct_messages/indicate_typing', { 191 | recipient_id: DIRECT_MESSAGE_RECIPIENT_ID, 192 | }); 193 | expect(response).toEqual({ _headers: expect.any(Object) }); 194 | }); 195 | 196 | it('should post status update with escaped characters, then delete it', async () => { 197 | const message = randomString(); // prevent overzealous abuse detection 198 | 199 | // https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update 200 | const response = await client.post('statuses/update', { 201 | status: STRING_WITH_SPECIAL_CHARS + message + STRING_WITH_SPECIAL_CHARS, 202 | }); 203 | 204 | expect(response).toMatchObject({ 205 | text: htmlEscape( 206 | STRING_WITH_SPECIAL_CHARS + message + STRING_WITH_SPECIAL_CHARS, 207 | ), 208 | }); 209 | const id = response.id_str; 210 | const deleted = await client.post('statuses/destroy', { 211 | id, 212 | }); 213 | expect(deleted).toMatchObject({ 214 | id_str: id, 215 | }); 216 | }); 217 | }); 218 | 219 | describe('uploading', () => { 220 | let uploadClient; 221 | beforeAll(() => (uploadClient = newClient('upload'))); 222 | 223 | it('should upload a picture, and add alt text to it', async () => { 224 | // Upload picture 225 | const base64Image = new Buffer(TEST_IMAGE).toString('base64'); 226 | const mediaUploadResponse = await uploadClient.post('media/upload', { 227 | media_data: base64Image, 228 | }); 229 | expect(mediaUploadResponse).toMatchObject({ 230 | media_id_string: expect.any(String), 231 | }); 232 | 233 | // Set alt text 234 | const imageAltString = 'Animated picture of a dancing banana'; 235 | await uploadClient.post('media/metadata/create', { 236 | media_id: mediaUploadResponse.media_id_string, 237 | alt_text: { text: imageAltString }, 238 | }); 239 | }); 240 | }); 241 | 242 | describe('putting', () => { 243 | let client; 244 | beforeAll(() => (client = newClient())); 245 | /** 246 | * For this test you need to have opted to receive messages from anyone at https://twitter.com/settings/safety 247 | * and your demo app needs to have access to read, write, and direct messages. 248 | */ 249 | it('can update welcome message', async () => { 250 | const newWelcomeMessage = await client.post( 251 | 'direct_messages/welcome_messages/new', 252 | { 253 | welcome_message: { 254 | name: 'simple_welcome-message 01', 255 | message_data: { 256 | text: 'Welcome!', 257 | }, 258 | }, 259 | }, 260 | ); 261 | 262 | const updatedWelcomeMessage = await client.put( 263 | 'direct_messages/welcome_messages/update', 264 | { 265 | id: newWelcomeMessage.welcome_message.id, 266 | }, 267 | { 268 | message_data: { 269 | text: 'Welcome!!!', 270 | }, 271 | }, 272 | ); 273 | 274 | expect(updatedWelcomeMessage.welcome_message.message_data.text).toEqual( 275 | 'Welcome!!!', 276 | ); 277 | }); 278 | }); 279 | 280 | describe('misc', () => { 281 | let client; 282 | beforeAll(() => (client = newClient())); 283 | 284 | it('should get full text of retweeted tweet', async () => { 285 | const response = await client.get('statuses/show', { 286 | id: '1019171288533749761', // a retweet by @dandv of @naval 287 | tweet_mode: 'extended', 288 | }); 289 | // This is @naval's original tweet 290 | expect(response.retweeted_status.full_text).toEqual( 291 | '@jdburns4 “Retirement” occurs when you stop sacrificing today for an imagined tomorrow. You can retire when your passive income exceeds your burn rate, or when you can make a living doing what you love.', 292 | ); 293 | // For the retweet, "truncated" comes misleadingly set to "false" from the API, and the "full_text" is limited to 140 chars 294 | expect(response.truncated).toEqual(false); 295 | expect(response.full_text).toEqual( 296 | 'RT @naval: @jdburns4 “Retirement” occurs when you stop sacrificing today for an imagined tomorrow. You can retire when your passive income…', 297 | ); 298 | }); 299 | 300 | it('should have favorited at least one tweet ever', async () => { 301 | const response = await client.get('favorites/list'); 302 | expect(response[0]).toHaveProperty('id_str'); 303 | }); 304 | 305 | it('should fail to follow unspecified user', async () => { 306 | expect.assertions(1); 307 | try { 308 | await client.post('friendships/create'); 309 | } catch (e) { 310 | expect(e).toMatchObject({ 311 | errors: [{ code: 108, message: 'Cannot find specified user.' }], 312 | }); 313 | } 314 | }); 315 | 316 | it('should follow user', async () => { 317 | const response = await client.post('friendships/create', { 318 | screen_name: 'mdo', 319 | }); 320 | expect(response).toMatchObject({ 321 | name: 'Mark Otto', 322 | }); 323 | }); 324 | 325 | it('should unfollow user', async () => { 326 | const response = await client.post('friendships/destroy', { 327 | user_id: '15008676', 328 | }); 329 | expect(response).toMatchObject({ 330 | name: 'Dan Dascalescu', 331 | }); 332 | }); 333 | 334 | it('should get details about 100 users with 18-character ids', async () => { 335 | const userIds = [ 336 | ...Array(99).fill('928759224599040001'), 337 | '711030662728437760', 338 | ].join(','); 339 | const expectedIds = [ 340 | { id_str: '928759224599040001' }, 341 | { id_str: '711030662728437760' }, 342 | ]; 343 | // Use POST per https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-lookup 344 | const usersPost = await client.post('users/lookup', { 345 | user_id: userIds, 346 | }); 347 | delete usersPost._headers; // to not confuse Jest - https://github.com/facebook/jest/issues/5998#issuecomment-446827454 348 | expect(usersPost).toMatchObject(expectedIds); 349 | // Check if GET worked the same 350 | const usersGet = await client.get('users/lookup', { user_id: userIds }); 351 | expect(usersGet.map((u) => u)).toMatchObject(expectedIds); // map(u => u) is an alternative to deleting _headers 352 | }); 353 | 354 | it('should be unable to get details about suspended user', async () => { 355 | const nonexistentScreenName = randomString() + randomString(); 356 | try { 357 | // https://twitter.com/fuckyou is actually a suspended user, but the API doesn't differentiate from nonexistent users 358 | await client.get('users/lookup', { 359 | screen_name: `fuckyou,${nonexistentScreenName}`, 360 | }); 361 | } catch (e) { 362 | expect(e).toMatchObject({ 363 | errors: [{ code: 17, message: 'No user matches for specified terms.' }], 364 | }); 365 | } 366 | }); 367 | 368 | it('should get timeline', async () => { 369 | const response = await client.get('statuses/user_timeline', { 370 | screen_name: 'twitterapi', 371 | count: 2, 372 | }); 373 | expect(response).toHaveLength(2); 374 | }); 375 | }); 376 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Lite 2 | 3 | A tiny, full-featured, modern client / server library for the [Twitter API](https://developer.twitter.com/en/docs/basics/things-every-developer-should-know). 4 | 5 | [![npm](https://img.shields.io/npm/v/twitter-lite.svg)](https://npm.im/twitter-lite) [![travis](https://travis-ci.org/draftbit/twitter-lite.svg?branch=master)](https://travis-ci.org/draftbit/twitter-lite) 6 | 7 | ## Features 8 | 9 | - Promise driven via Async / Await 10 | - REST and Stream support 11 | - [Typescript support](https://github.com/draftbit/twitter-lite/blob/master/index.d.ts) 12 | - Works in Node.js 13 | - Rate limiting support 14 | - Under 1kb 15 | - Minimal dependencies 16 | - Test suite 17 | 18 | ## Why 19 | 20 | We have built this library because existing ones [have not been recently maintained](https://github.com/desmondmorris/node-twitter), or depend on [outdated](https://github.com/ttezel/twit/issues/411) [libraries](https://github.com/ttezel/twit/issues/412). 21 | 22 | ## Installation 23 | 24 | ```sh 25 | yarn add twitter-lite 26 | ``` 27 | 28 | ```sh 29 | npm install twitter-lite 30 | ``` 31 | 32 | Then you can include the following at the top of your code: 33 | 34 | ```es6 35 | import Twitter from 'twitter-lite'; 36 | 37 | const client = new Twitter({ 38 | ... 39 | }) 40 | 41 | client.get(...) 42 | client.post(...) 43 | ``` 44 | 45 | ## Usage 46 | 47 | - Create an app on [https://apps.twitter.com/](https://apps.twitter.com) 48 | - Grab the Consumer Key (API Key) and Consumer Secret (API Secret) from Keys and Access Tokens 49 | - Make sure you set the right access level for your app 50 | - If you want to use user-based authentication, grab the access token key and secret as well 51 | 52 | ### App vs. User authentication 53 | 54 | Twitter has two different authentication options: 55 | 56 | - App: higher rate limits. Great for building your own Twitter App. 57 | - User: lower rate limits. Great for making requests on behalf of a User. 58 | 59 | **User** authentication requires: 60 | 61 | - `consumer_key` 62 | - `consumer_secret` 63 | - `access_token_key` 64 | - `access_token_secret` 65 | 66 | **App** authentication requires: 67 | 68 | - `bearer_token` 69 | 70 | App authentication is a simple header behind the scenes: 71 | 72 | ```es6 73 | headers: { 74 | Authorization: `Bearer ${bearer_token}`; 75 | } 76 | ``` 77 | 78 | You can get the bearer token by calling `.getBearerToken()`. 79 | 80 | ### Verifying credentials example (user auth) 81 | 82 | ```es6 83 | const client = new Twitter({ 84 | subdomain: "api", // "api" is the default (change for other subdomains) 85 | version: "1.1", // version "1.1" is the default (change for other subdomains) 86 | consumer_key: "abc", // from Twitter. 87 | consumer_secret: "def", // from Twitter. 88 | access_token_key: "uvw", // from your User (oauth_token) 89 | access_token_secret: "xyz" // from your User (oauth_token_secret) 90 | }); 91 | 92 | client 93 | .get("account/verify_credentials") 94 | .then(results => { 95 | console.log("results", results); 96 | }) 97 | .catch(console.error); 98 | ``` 99 | 100 | ### App authentication example 101 | 102 | ```es6 103 | const user = new Twitter({ 104 | consumer_key: "abc", 105 | consumer_secret: "def" 106 | }); 107 | 108 | const response = await user.getBearerToken(); 109 | const app = new Twitter({ 110 | bearer_token: response.access_token 111 | }); 112 | ``` 113 | 114 | ### Oauth authentication 115 | 116 | According to the [docs](https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate) this helps you get access token from your users. 117 | 118 | - [Request Token documentation](https://developer.twitter.com/en/docs/basics/authentication/api-reference/request_token) 119 | - [Access Token documentation](https://developer.twitter.com/en/docs/basics/authentication/api-reference/access_token) 120 | 121 | ```es6 122 | const client = new Twitter({ 123 | consumer_key: "xyz", 124 | consumer_secret: "xyz" 125 | }); 126 | 127 | client 128 | .getRequestToken("http://callbackurl.com") 129 | .then(res => 130 | console.log({ 131 | reqTkn: res.oauth_token, 132 | reqTknSecret: res.oauth_token_secret 133 | }) 134 | ) 135 | .catch(console.error); 136 | ``` 137 | 138 | Then you redirect your user to `https://api.twitter.com/oauth/authenticate?oauth_token=xyz123abc`, and once you get the verifier and the token, you pass them on to the [next stage of the authentication](https://developer.twitter.com/en/docs/basics/authentication/api-reference/access_token). 139 | 140 | ```es6 141 | const client = new Twitter({ 142 | consumer_key: "xyz", 143 | consumer_secret: "xyz" 144 | }); 145 | 146 | client 147 | .getAccessToken({ 148 | oauth_verifier: oauthVerifier, 149 | oauth_token: oauthToken 150 | }) 151 | .then(res => 152 | console.log({ 153 | accTkn: res.oauth_token, 154 | accTknSecret: res.oauth_token_secret, 155 | userId: res.user_id, 156 | screenName: res.screen_name 157 | }) 158 | ) 159 | .catch(console.error); 160 | ``` 161 | 162 | And this will return you your `access_token` and `access_token_secret`. 163 | 164 | ### Tweeting a thread 165 | 166 | - [statuses/update documentation](https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update) 167 | 168 | ```es6 169 | const client = new Twitter({ 170 | consumer_key: "xyz", 171 | consumer_secret: "xyz", 172 | access_token_key: "xyz", 173 | access_token_secret: "xyz" 174 | }); 175 | 176 | async function tweetThread(thread) { 177 | let lastTweetID = ""; 178 | for (const status of thread) { 179 | const tweet = await client.post("statuses/update", { 180 | status: status, 181 | in_reply_to_status_id: lastTweetID, 182 | auto_populate_reply_metadata: true 183 | }); 184 | lastTweetID = tweet.id_str; 185 | } 186 | } 187 | 188 | const thread = ["First tweet", "Second tweet", "Third tweet"]; 189 | tweetThread(thread).catch(console.error); 190 | ``` 191 | 192 | ## Streams 193 | 194 | To learn more about the streaming API visit the [Twitter Docs](https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter.html). The streaming API works only in Node. 195 | 196 | ```es6 197 | const client = new Twitter({ 198 | consumer_key: "xyz" // from Twitter. 199 | consumer_secret: "xyz" // from Twitter. 200 | access_token_key: "abc" // from your User (oauth_token) 201 | access_token_secret: "abc" // from your User (oauth_token_secret) 202 | }); 203 | 204 | const parameters = { 205 | track: "#bitcoin,#litecoin,#monero", 206 | follow: "422297024,873788249839370240", // @OrchardAI, @tylerbuchea 207 | locations: "-122.75,36.8,-121.75,37.8", // Bounding box - San Francisco 208 | }; 209 | 210 | const stream = client.stream("statuses/filter", parameters) 211 | .on("start", response => console.log("start")) 212 | .on("data", tweet => console.log("data", tweet.text)) 213 | .on("ping", () => console.log("ping")) 214 | .on("error", error => console.log("error", error)) 215 | .on("end", response => console.log("end")); 216 | 217 | // To stop the stream: 218 | process.nextTick(() => stream.destroy()); // emits "end" and "error" events 219 | ``` 220 | 221 | To stop a stream, call `stream.destroy()`. That might take a while though, if the stream receives a lot of traffic. Also, if you attempt to destroy a stream from an `on` handler, you _may_ get an error about writing to a destroyed stream. 222 | In that case, try to [defer](https://stackoverflow.com/questions/49804108/write-after-end-stream-error/53878933#53878933) the `destroy()` call: 223 | 224 | ```es6 225 | process.nextTick(() => stream.destroy()); 226 | ``` 227 | 228 | After calling `stream.destroy()`, you can recreate the stream, if you wait long enough - see the "should reuse stream N times" test. Note that Twitter may return a "420 Enhance your calm" error if you switch streams too fast. There are no response headers specifying how long to wait, and [the error](https://stackoverflow.com/questions/13438965/avoid-420s-with-streaming-api), as well as [streaming limits](https://stackoverflow.com/questions/34962677/twitter-streaming-api-limits) in general, are poorly documented. Trial and error has shown that for tracked keywords, waiting 20 to 30 seconds between re-creating streams was enough. Remember to also set up the `.on()` handlers again for the new stream. 229 | 230 | ## Support for Twitter API v2 231 | 232 | The new Twitter API v2 no longer requires the `.json` extension on its endpoints. In order to use `v2`, set `version: '2'` and `extension: false`. 233 | 234 | ```es6 235 | const client = new Twitter({ 236 | version: "2", // version "1.1" is the default (change for v2) 237 | extension: false, // true is the default (this must be set to false for v2 endpoints) 238 | consumer_key: "abc", // from Twitter. 239 | consumer_secret: "def", // from Twitter. 240 | access_token_key: "uvw", // from your User (oauth_token) 241 | access_token_secret: "xyz" // from your User (oauth_token_secret) 242 | }); 243 | ``` 244 | 245 | ## Methods 246 | 247 | ### .get(endpoint, parameters) 248 | 249 | Returns a Promise resolving to the API response object, or rejecting on error. The response and error objects also contain the HTTP response code and [headers](https://developer.twitter.com/en/docs/basics/rate-limiting.html), under the `_headers` key. These are useful to check for [rate limit](#rate-limiting) information. 250 | 251 | ```es6 252 | const client = new Twitter({ 253 | consumer_key: "xyz", 254 | consumer_secret: "xyz", 255 | access_token_key: "abc", 256 | access_token_secret: "abc" 257 | }); 258 | 259 | const rateLimits = await client.get("statuses/show", { 260 | id: "1016078154497048576" 261 | }); 262 | ``` 263 | 264 | ### .post(endpoint, parameters) 265 | 266 | Same return as `.get()`. 267 | 268 | Use the `.post` method for actions that change state, or when the total size of the parameters might be too long for a GET request. For [example](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-create.html), to follow a user: 269 | 270 | ```es6 271 | const client = new Twitter({ 272 | consumer_key: "xyz", 273 | consumer_secret: "xyz", 274 | access_token_key: "abc", 275 | access_token_secret: "abc" 276 | }); 277 | 278 | await client.post("friendships/create", { 279 | screen_name: "dandv" 280 | }); 281 | ``` 282 | 283 | The second use case for POST is when you need to pass more parameters than suitable for the length of a URL, such as when [looking up a larger number of user ids or screen names](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-lookup): 284 | 285 | ```es6 286 | const users = await client.post("users/lookup", { 287 | screen_name: "longScreenName1,longerScreeName2,...,veryLongScreenName100" 288 | }); 289 | ``` 290 | 291 | ### .put(endpoint, query_parameters, request_body) 292 | 293 | Same return as `.get()` and `.post()`. 294 | 295 | Use the `.put` method for actions that update state. For [example](https://developer.twitter.com/en/docs/direct-messages/welcome-messages/api-reference/update-welcome-message), to update a welcome message. 296 | 297 | ```es6 298 | const client = new Twitter({ 299 | consumer_key: "xyz", 300 | consumer_secret: "xyz", 301 | access_token_key: "abc", 302 | access_token_secret: "abc" 303 | }); 304 | 305 | const welcomeMessageID = "abc"; 306 | 307 | await client.put( 308 | "direct_messages/welcome_messages/update", 309 | { 310 | id: welcomeMessageID 311 | }, 312 | { 313 | message_data: { 314 | text: "Welcome!!!" 315 | } 316 | } 317 | ); 318 | ``` 319 | 320 | ### .getBearerToken() 321 | 322 | See the [app authentication example](#app-authentication-example). 323 | 324 | ### .getRequestToken(twitterCallbackUrl) 325 | 326 | See the [OAuth example](#oauth-authentication). 327 | 328 | ### .getAccessToken(options) 329 | 330 | See the [OAuth example](#oauth-authentication). 331 | 332 | ## Examples 333 | 334 | You can find many more examples for various resources/endpoints in [the tests](test). 335 | 336 | ## Troubleshooting 337 | 338 | ### Headers on success 339 | 340 | ```es6 341 | const tweets = await client.get("statuses/home_timeline"); 342 | console.log(`Rate: ${tweets._headers.get('x-rate-limit-remaining')} / ${tweets._headers.get('x-rate-limit-limit')}`); 343 | const delta = (tweets._headers.get('x-rate-limit-reset') * 1000) - Date.now() 344 | console.log(`Reset: ${Math.ceil(delta / 1000 / 60)} minutes`); 345 | ``` 346 | 347 | ### API errors 348 | 349 | `.get` and `.post` reject on error, so you can use try/catch to handle errors. The error object contains an `errors` property with the error `code` and `message`, and a `_headers` property with the the HTTP response code and [Headers](https://developer.twitter.com/en/docs/basics/rate-limiting.html) object returned by the Twitter API. 350 | 351 | ```es6 352 | try { 353 | const response = await client.get("some/endpoint"); 354 | // ... use response here ... 355 | } catch (e) { 356 | if ('errors' in e) { 357 | // Twitter API error 358 | if (e.errors[0].code === 88) 359 | // rate limit exceeded 360 | console.log("Rate limit will reset on", new Date(e._headers.get("x-rate-limit-reset") * 1000)); 361 | else 362 | // some other kind of error, e.g. read-only API trying to POST 363 | } else { 364 | // non-API error, e.g. network problem or invalid JSON in response 365 | } 366 | } 367 | ``` 368 | 369 | #### Rate limiting 370 | 371 | A particular case of errors is exceeding the [rate limits](https://developer.twitter.com/en/docs/basics/rate-limits.html). See the example immediately above for detecting rate limit errors, and read [Twitter's documentation on rate limiting](https://developer.twitter.com/en/docs/basics/rate-limiting.html). 372 | 373 | ### Numeric vs. string IDs 374 | 375 | Twitter uses [numeric IDs](https://developer.twitter.com/en/docs/basics/twitter-ids.html) that in practice can be up to 18 characters long. Due to rounding errors, it's [unsafe to use numeric IDs in JavaScript](https://developer.twitter.com/en/docs/basics/things-every-developer-should-know). Always set `stringify_ids: true` when possible, so that Twitter will return strings instead of numbers, and rely on the `id_str` field, rather than on the `id` field. 376 | 377 | ## Contributing 378 | 379 | With the library nearing v1.0, contributions are welcome! Areas especially in need of help involve multimedia (see [#33](https://github.com/draftbit/twitter-lite/issues/33) for example), and adding tests (see [these](https://github.com/ttezel/twit/tree/master/tests) for reference). 380 | 381 | ### Development 382 | 383 | 1. Fork/clone the repo 384 | 2. `yarn/npm install` 385 | 3. Go to and create an app for testing this module. Make sure it has read/write permissions. 386 | 4. Grab the consumer key/secret, and the access token/secret and place them in a [.env](https://www.npmjs.com/package/dotenv) file in the project's root directory, under the following variables: 387 | ``` 388 | TWITTER_CONSUMER_KEY=... 389 | TWITTER_CONSUMER_SECRET=... 390 | ACCESS_TOKEN=... 391 | ACCESS_TOKEN_SECRET=... 392 | ``` 393 | 5. `yarn/npm test` and make sure all tests pass 394 | 6. Add your contribution, along with test case(s). Note: feel free to skip the ["should DM user"](https://github.com/draftbit/twitter-lite/blob/34e8dbb3efb9a45564275f16473af59dbc4409e5/twitter.test.js#L167) test during development by changing that `it()` call to `it.skip()`, but remember to revert that change before committing. This will prevent your account from being flagged as [abusing the API to send too many DMs](https://github.com/draftbit/twitter-lite/commit/5ee2ce4232faa07453ea2f0b4d63ee7a6d119ce7). 395 | 7. Make sure all tests pass. **NOTE: tests will take over 10 minutes to finish.** 396 | 8. Commit using a [descriptive message](https://chris.beams.io/posts/git-commit/) (please squash commits into one per fix/improvement!) 397 | 9. `git push` and submit your PR! 398 | 399 | ## Credits 400 | 401 | Authors: 402 | 403 | - [@dandv](https://github.com/dandv) 404 | - [@peterpme](https://github.com/peterpme) 405 | 406 | Over the years, thanks to: 407 | 408 | - [@ttezel](https://github.com/ttezel) 409 | - [@technoweenie](http://github.com/technoweenie) 410 | - [@jdub](http://github.com/jdub) 411 | - [@desmondmorris](http://github.com/desmondmorris) 412 | - [Node Twitter 413 | Community](https://github.com/desmondmorris/node-twitter/graphs/contributors) 414 | - [@dylanirlbeck](https://github.com/dylanirlbeck) 415 | - [@Fdebijl](https://github.com/Fdebijl) - Typescript support 416 | --------------------------------------------------------------------------------