├── .npmignore ├── .gitignore ├── tests ├── img │ ├── bigbird.jpg │ ├── cutebird.png │ ├── twitterbird.gif │ └── snoopy-animated.gif ├── video │ └── station.mp4 ├── test_helpers.js ├── user_stream.js ├── helpers.js ├── multiple-conn.js ├── rest_app_only_auth.js ├── rest_chunked_upload.js ├── twit.js ├── streaming.js └── rest.js ├── lib ├── settings.js ├── endpoints.js ├── parser.js ├── helpers.js ├── file_uploader.js ├── streaming-api-connection.js └── twitter.js ├── package.json ├── examples ├── rtd2.js └── bot.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config*.js 3 | test.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config*.js 3 | test.js 4 | .DS_Store -------------------------------------------------------------------------------- /tests/img/bigbird.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttezel/twit/HEAD/tests/img/bigbird.jpg -------------------------------------------------------------------------------- /tests/img/cutebird.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttezel/twit/HEAD/tests/img/cutebird.png -------------------------------------------------------------------------------- /tests/video/station.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttezel/twit/HEAD/tests/video/station.mp4 -------------------------------------------------------------------------------- /tests/img/twitterbird.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttezel/twit/HEAD/tests/img/twitterbird.gif -------------------------------------------------------------------------------- /tests/img/snoopy-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttezel/twit/HEAD/tests/img/snoopy-animated.gif -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | // set of status codes where we don't attempt reconnecting to Twitter 2 | exports.STATUS_CODES_TO_ABORT_ON = [ 400, 401, 403, 404, 406, 410, 413, 422 ]; 3 | -------------------------------------------------------------------------------- /lib/endpoints.js: -------------------------------------------------------------------------------- 1 | // Twitter Endpoints 2 | module.exports = { 3 | API_HOST : 'https://api.twitter.com/' 4 | , REST_ROOT : 'https://api.twitter.com/1.1/' 5 | , PUB_STREAM : 'https://stream.twitter.com/1.1/' 6 | , USER_STREAM : 'https://userstream.twitter.com/1.1/' 7 | , SITE_STREAM : 'https://sitestream.twitter.com/1.1/' 8 | , MEDIA_UPLOAD : 'https://upload.twitter.com/1.1/' 9 | , OA_REQ : 'https://api.twitter.com/oauth/request_token' 10 | , OA_ACCESS : 'https://api.twitter.com/oauth/access_token' 11 | } -------------------------------------------------------------------------------- /tests/test_helpers.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var helpers = require('../lib/helpers') 3 | 4 | describe('makeQueryString', function () { 5 | it('correctly encodes Objects with String values', function () { 6 | assert.equal(helpers.makeQueryString({a: 'Ladies + Gentlemen'}), 'a=Ladies%20%2B%20Gentlemen'); 7 | assert.equal(helpers.makeQueryString({a: 'An encoded string!'}), 'a=An%20encoded%20string%21'); 8 | assert.equal(helpers.makeQueryString({a: 'Dogs, Cats & Mice'}), 'a=Dogs%2C%20Cats%20%26%20Mice') 9 | assert.equal(helpers.makeQueryString({a: '☃'}), 'a=%E2%98%83') 10 | assert.equal(helpers.makeQueryString({a: '#haiku #poetry'}), 'a=%23haiku%20%23poetry') 11 | assert.equal(helpers.makeQueryString({a: '"happy hour" :)'}), 'a=%22happy%20hour%22%20%3A%29') 12 | }) 13 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twit", 3 | "description": "Twitter API client for node (REST & Streaming)", 4 | "version": "2.2.11", 5 | "author": "Tolga Tezel", 6 | "keywords": [ 7 | "twitter", 8 | "api", 9 | "rest", 10 | "stream", 11 | "streaming", 12 | "oauth" 13 | ], 14 | "dependencies": { 15 | "bluebird": "^3.1.5", 16 | "mime": "^1.3.4", 17 | "request": "^2.68.0" 18 | }, 19 | "devDependencies": { 20 | "async": "0.2.9", 21 | "colors": "0.6.x", 22 | "commander": "2.6.0", 23 | "mocha": "2.1.0", 24 | "rewire": "2.3.4", 25 | "sinon": "1.15.4" 26 | }, 27 | "engines": { 28 | "node": ">=0.10.0" 29 | }, 30 | "license": "MIT", 31 | "main": "./lib/twitter", 32 | "repository": { 33 | "type": "git", 34 | "url": "http://github.com/ttezel/twit.git" 35 | }, 36 | "scripts": { 37 | "test": "./node_modules/.bin/mocha tests/* -t 70000 -R spec --bail --globals domain,_events,_maxListeners" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/user_stream.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var Twit = require('../lib/twitter') 3 | var config1 = require('../config1') 4 | var streaming = require('./streaming') 5 | 6 | //verify `friendsMsg` is a twitter 'friends' message object 7 | function checkFriendsMsg (friendsMsg) { 8 | var friendIds = friendsMsg.friends 9 | 10 | assert(friendIds) 11 | assert(Array.isArray(friendIds)) 12 | assert(friendIds[0]) 13 | } 14 | 15 | describe('user events', function () { 16 | it('friends', function (done) { 17 | var twit = new Twit(config1); 18 | var stream = twit.stream('user'); 19 | 20 | //make sure we're connected to the right endpoint 21 | assert.equal(stream.reqOpts.url, 'https://userstream.twitter.com/1.1/user.json') 22 | 23 | stream.on('friends', function (friendsMsg) { 24 | checkFriendsMsg(friendsMsg) 25 | 26 | stream.stop() 27 | done() 28 | }) 29 | 30 | stream.on('connect', function () { 31 | console.log('\nuser stream connecting..') 32 | }) 33 | 34 | stream.on('connected', function () { 35 | console.log('user stream connected.') 36 | }) 37 | }) 38 | }) -------------------------------------------------------------------------------- /tests/helpers.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var stream = require('stream'); 3 | var util = require('util'); 4 | 5 | // Used to stub out calls to `request`. 6 | exports.FakeRequest = function () { 7 | EventEmitter.call(this) 8 | } 9 | util.inherits(exports.FakeRequest, EventEmitter) 10 | exports.FakeRequest.prototype.destroy = function () { 11 | 12 | } 13 | 14 | // Used to stub out the http.IncomingMessage object emitted by the "response" event on `request`. 15 | exports.FakeResponse = function (statusCode, body) { 16 | if (!body) { 17 | body = ''; 18 | } 19 | this.statusCode = statusCode; 20 | stream.Readable.call(this); 21 | this.push(body); 22 | this.push(null); 23 | } 24 | util.inherits(exports.FakeResponse, stream.Readable); 25 | 26 | exports.FakeResponse.prototype._read = function () { 27 | 28 | } 29 | exports.FakeResponse.prototype.destroy = function () { 30 | 31 | } 32 | 33 | exports.generateRandomString = function generateRandomString (length) { 34 | var length = length || 10 35 | var ret = '' 36 | var alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789' 37 | for (var i = 0; i < length; i++) { 38 | // use an easy set of unicode as an alphabet - twitter won't reformat them 39 | // which makes testing easier 40 | ret += alphabet[Math.floor(Math.random()*alphabet.length)] 41 | } 42 | 43 | ret = encodeURI(ret) 44 | 45 | return ret 46 | } 47 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | // 2 | // Parser - for Twitter Streaming API 3 | // 4 | var util = require('util') 5 | , EventEmitter = require('events').EventEmitter; 6 | 7 | var Parser = module.exports = function () { 8 | this.message = '' 9 | 10 | EventEmitter.call(this); 11 | }; 12 | 13 | util.inherits(Parser, EventEmitter); 14 | 15 | Parser.prototype.parse = function (chunk) { 16 | this.message += chunk; 17 | chunk = this.message; 18 | 19 | var size = chunk.length 20 | , start = 0 21 | , offset = 0 22 | , curr 23 | , next; 24 | 25 | while (offset < size) { 26 | curr = chunk[offset]; 27 | next = chunk[offset + 1]; 28 | 29 | if (curr === '\r' && next === '\n') { 30 | var piece = chunk.slice(start, offset); 31 | start = offset += 2; 32 | 33 | if (!piece.length) { continue; } //empty object 34 | 35 | if (piece === 'Exceeded connection limit for user') { 36 | this.emit('connection-limit-exceeded', 37 | new Error('Twitter says: ' + piece + '. Only instantiate one stream per set of credentials.')); 38 | continue; 39 | } 40 | 41 | try { 42 | var msg = JSON.parse(piece) 43 | } catch (err) { 44 | this.emit('error', new Error('Error parsing twitter reply: `'+piece+'`, error message `'+err+'`')); 45 | } finally { 46 | if (msg) 47 | this.emit('element', msg) 48 | 49 | continue 50 | } 51 | } 52 | offset++; 53 | } 54 | 55 | this.message = chunk.slice(start, size); 56 | }; 57 | -------------------------------------------------------------------------------- /examples/rtd2.js: -------------------------------------------------------------------------------- 1 | // 2 | // RTD2 - Twitter bot that tweets about the most popular github.com news 3 | // Also makes new friends and prunes its followings. 4 | // 5 | var Bot = require('./bot') 6 | , config1 = require('../config1'); 7 | 8 | var bot = new Bot(config1); 9 | 10 | console.log('RTD2: Running.'); 11 | 12 | //get date string for today's date (e.g. '2011-01-01') 13 | function datestring () { 14 | var d = new Date(Date.now() - 5*60*60*1000); //est timezone 15 | return d.getUTCFullYear() + '-' 16 | + (d.getUTCMonth() + 1) + '-' 17 | + d.getDate(); 18 | }; 19 | 20 | setInterval(function() { 21 | bot.twit.get('followers/ids', function(err, reply) { 22 | if(err) return handleError(err) 23 | console.log('\n# followers:' + reply.ids.length.toString()); 24 | }); 25 | var rand = Math.random(); 26 | 27 | if(rand <= 0.10) { // tweet popular github tweet 28 | var params = { 29 | q: 'github.com/' 30 | , since: datestring() 31 | , result_type: 'mixed' 32 | }; 33 | bot.twit.get('search/tweets', params, function (err, reply) { 34 | if(err) return handleError(err); 35 | 36 | var max = 0, popular; 37 | 38 | var tweets = reply.statuses 39 | , i = tweets.length; 40 | 41 | while(i--) { 42 | var tweet = tweets[i] 43 | , popularity = tweet.retweet_count; 44 | 45 | if(popularity > max) { 46 | max = popularity; 47 | popular = tweet.text; 48 | } 49 | } 50 | 51 | bot.tweet(popular, function (err, reply) { 52 | if(err) return handleError(err); 53 | 54 | console.log('\nTweet: ' + (reply ? reply.text : reply)); 55 | }) 56 | }); 57 | } else if(rand <= 0.55) { // make a friend 58 | bot.mingle(function(err, reply) { 59 | if(err) return handleError(err); 60 | 61 | var name = reply.screen_name; 62 | console.log('\nMingle: followed @' + name); 63 | }); 64 | } else { // prune a friend 65 | bot.prune(function(err, reply) { 66 | if(err) return handleError(err); 67 | 68 | var name = reply.screen_name 69 | console.log('\nPrune: unfollowed @'+ name); 70 | }); 71 | } 72 | }, 40000); 73 | 74 | function handleError(err) { 75 | console.error('response status:', err.statusCode); 76 | console.error('data:', err.data); 77 | } 78 | -------------------------------------------------------------------------------- /tests/multiple-conn.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var Twit = require('../lib/twitter'); 4 | var config1 = require('../config1'); 5 | var colors = require('colors'); 6 | var restTest = require('./rest.js'); 7 | 8 | /* 9 | Don't run these tests often otherwise Twitter will rate limit you 10 | */ 11 | 12 | describe.skip('multiple connections', function () { 13 | it('results in one of the streams closing', function (done) { 14 | var twit = new Twit(config1); 15 | 16 | var streams = [ 17 | twit.stream('statuses/sample'), 18 | twit.stream('statuses/sample'), 19 | twit.stream('statuses/sample'), 20 | ]; 21 | 22 | streams.forEach(function (stream, i) { 23 | stream.on('disconnect', function (disconnect) { 24 | console.log('Disconect for stream', i) 25 | assert.equal(typeof disconnect, 'object'); 26 | done(); 27 | }); 28 | 29 | stream.on('error', function (errMsg) { 30 | console.log('error for stream', i, errMsg) 31 | }) 32 | 33 | stream.on('tweet', function (t) { 34 | console.log(i) 35 | }) 36 | 37 | stream.on('connected', function () { 38 | console.log('Stream', i, 'connected.') 39 | }) 40 | }); 41 | 42 | }); 43 | }); 44 | 45 | describe.skip('Managing multiple streams legally', function () { 46 | this.timeout(60000); 47 | it('updating track keywords without losing data', function (done) { 48 | var twit = new Twit(config1); 49 | var stream1 = twit.stream('statuses/filter', { track: ['#no'] }); 50 | 51 | stream1.once('tweet', function (tweet) { 52 | console.log('got tweet from first stream') 53 | restTest.checkTweet(tweet); 54 | restTest.assertTweetHasText(tweet, '#no'); 55 | 56 | // update our track list and initiate a new connection 57 | var stream2 = twit.stream('statuses/filter', { track: ['#fun'] }); 58 | 59 | stream2.once('connected', function (res) { 60 | console.log('second stream connected') 61 | // stop the first stream immediately 62 | stream1.stop(); 63 | assert.equal(res.statusCode, 200) 64 | 65 | stream2.once('tweet', function (tweet) { 66 | restTest.checkTweet(tweet); 67 | 68 | restTest.assertTweetHasText(tweet, '#fun'); 69 | return done(); 70 | }) 71 | }); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/rest_app_only_auth.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | 3 | var config1 = require('../config1'); 4 | var Twit = require('../lib/twitter'); 5 | var checkReply = require('./rest').checkReply; 6 | var checkResponse = require('./rest').checkResponse; 7 | var checkTweet = require('./rest').checkTweet; 8 | 9 | describe('REST API using app-only auth', function () { 10 | var twit = null 11 | before(function () { 12 | var config = { 13 | consumer_key: config1.consumer_key, 14 | consumer_secret: config1.consumer_secret, 15 | app_only_auth: true, 16 | } 17 | twit = new Twit(config) 18 | }) 19 | 20 | it('GET `application/rate_limit_status`', function (done) { 21 | twit.get('application/rate_limit_status', function (err, body, response) { 22 | checkReply(err, body) 23 | checkResponse(response) 24 | assert(body.rate_limit_context) 25 | done() 26 | }) 27 | }) 28 | 29 | it('GET `application/rate_limit_status with specific resource`', function (done) { 30 | var params = { resources: [ 'users', 'search' ]} 31 | twit.get('application/rate_limit_status', params, function (err, body, response) { 32 | checkReply(err, body) 33 | checkResponse(response) 34 | assert(body.rate_limit_context) 35 | assert(body.resources.search) 36 | assert.equal(Object.keys(body.resources).length, 2) 37 | done() 38 | }) 39 | }) 40 | 41 | it('GET `search/tweets` { q: "a", since_id: 12345 }', function (done) { 42 | var params = { q: 'a', since_id: 12345 } 43 | twit.get('search/tweets', params, function (err, reply, response) { 44 | checkReply(err, reply) 45 | assert.ok(reply.statuses) 46 | checkTweet(reply.statuses[0]) 47 | 48 | checkResponse(response) 49 | 50 | done() 51 | }) 52 | }) 53 | 54 | it('GET `users/show` { screen_name: twitter } multiple times', function (done) { 55 | var index = 0 56 | var limit = 50 57 | var params = { screen_name: 'twitter' } 58 | var isDone = false; 59 | 60 | var getData = function () { 61 | twit.get('users/show', params, function (err, data) { 62 | if (data) { 63 | assert.equal(783214, data.id) 64 | } else { 65 | throw new Error(err) 66 | } 67 | index++ 68 | 69 | if (index >= limit && !isDone) { 70 | isDone = true; 71 | done(); 72 | } 73 | }) 74 | } 75 | 76 | for (var i = 0; i <= limit; i++) { 77 | getData() 78 | } 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /tests/rest_chunked_upload.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var fs = require('fs'); 3 | var mime = require('mime'); 4 | var path = require('path'); 5 | 6 | var config = require('../config1'); 7 | var Twit = require('../lib/twitter'); 8 | 9 | describe('twit.postMediaChunked', function () { 10 | it('Posting media via twit.postMediaChunked works with .mp4', function (done) { 11 | var twit = new Twit(config); 12 | var mediaFilePath = path.join(__dirname, './video/station.mp4'); 13 | twit.postMediaChunked({ file_path: mediaFilePath }, function (err, bodyObj, resp) { 14 | exports.checkUploadMedia(err, bodyObj, resp) 15 | done() 16 | }) 17 | }) 18 | 19 | it('POST media/upload via manual commands works with .mp4', function (done) { 20 | var mediaFilePath = path.join(__dirname, './video/station.mp4'); 21 | var mediaType = mime.lookup(mediaFilePath); 22 | var mediaFileSizeBytes = fs.statSync(mediaFilePath).size; 23 | 24 | var twit = new Twit(config); 25 | twit.post('media/upload', { 26 | 'command': 'INIT', 27 | 'media_type': mediaType, 28 | 'total_bytes': mediaFileSizeBytes 29 | }, function (err, bodyObj, resp) { 30 | assert(!err, err); 31 | var mediaIdStr = bodyObj.media_id_string; 32 | 33 | var isStreamingFile = true; 34 | var isUploading = false; 35 | var segmentIndex = 0; 36 | var fStream = fs.createReadStream(mediaFilePath, { highWaterMark: 5 * 1024 * 1024 }); 37 | 38 | var _finalizeMedia = function (mediaIdStr, cb) { 39 | twit.post('media/upload', { 40 | 'command': 'FINALIZE', 41 | 'media_id': mediaIdStr 42 | }, cb) 43 | } 44 | 45 | var _checkFinalizeResp = function (err, bodyObj, resp) { 46 | exports.checkUploadMedia(err, bodyObj, resp) 47 | done(); 48 | } 49 | 50 | fStream.on('data', function (buff) { 51 | fStream.pause(); 52 | isStreamingFile = false; 53 | isUploading = true; 54 | 55 | twit.post('media/upload', { 56 | 'command': 'APPEND', 57 | 'media_id': mediaIdStr, 58 | 'segment_index': segmentIndex, 59 | 'media': buff.toString('base64'), 60 | }, function (err, bodyObj, resp) { 61 | assert(!err, err); 62 | isUploading = false; 63 | 64 | if (!isStreamingFile) { 65 | _finalizeMedia(mediaIdStr, _checkFinalizeResp); 66 | } 67 | }); 68 | }); 69 | 70 | fStream.on('end', function () { 71 | isStreamingFile = false; 72 | 73 | if (!isUploading) { 74 | _finalizeMedia(mediaIdStr, _checkFinalizeResp); 75 | } 76 | }); 77 | }); 78 | }) 79 | }) 80 | 81 | exports.checkUploadMedia = function (err, bodyObj, resp) { 82 | assert(!err, err) 83 | 84 | assert(bodyObj) 85 | assert(bodyObj.media_id) 86 | assert(bodyObj.media_id_string) 87 | assert(bodyObj.size) 88 | } 89 | -------------------------------------------------------------------------------- /tests/twit.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , Twit = require('../lib/twitter') 3 | , config1 = require('../config1') 4 | 5 | describe('twit', function () { 6 | describe('instantiation', function () { 7 | it('works with var twit = new Twit()', function () { 8 | var twit = new Twit({ 9 | consumer_key: 'a', 10 | consumer_secret: 'b', 11 | access_token: 'c', 12 | access_token_secret: 'd' 13 | }); 14 | assert(twit.config) 15 | assert.equal(typeof twit.get, 'function') 16 | assert.equal(typeof twit.post, 'function') 17 | assert.equal(typeof twit.stream, 'function') 18 | }) 19 | it('works with var twit = Twit()', function () { 20 | var twit = Twit({ 21 | consumer_key: 'a', 22 | consumer_secret: 'b', 23 | access_token: 'c', 24 | access_token_secret: 'd' 25 | }); 26 | assert(twit.config) 27 | assert.equal(typeof twit.get, 'function') 28 | assert.equal(typeof twit.post, 'function') 29 | assert.equal(typeof twit.stream, 'function') 30 | }) 31 | }) 32 | 33 | describe('config', function () { 34 | it('throws when passing empty config', function (done) { 35 | assert.throws(function () { 36 | var twit = new Twit({}) 37 | }, Error) 38 | 39 | done() 40 | }) 41 | 42 | it('throws when config is missing a required key', function (done) { 43 | assert.throws(function () { 44 | var twit = new Twit({ 45 | consumer_key: 'a' 46 | , consumer_secret: 'a' 47 | , access_token: 'a' 48 | }) 49 | }, Error) 50 | 51 | done() 52 | }) 53 | 54 | it('throws when config provides all keys but they\'re empty strings', function (done) { 55 | assert.throws(function () { 56 | var twit = new Twit({ 57 | consumer_key: '' 58 | , consumer_secret: '' 59 | , access_token: '' 60 | , access_token_secret: '' 61 | }) 62 | }, Error) 63 | 64 | done() 65 | }) 66 | 67 | it('throws when config provides invalid strictSSL option', function (done) { 68 | assert.throws(function () { 69 | var twit = new Twit({ 70 | consumer_key: 'a' 71 | , consumer_secret: 'a' 72 | , access_token: 'a' 73 | , access_token_secret: 'a' 74 | , strictSSL: 'a' 75 | }) 76 | }, Error) 77 | 78 | done() 79 | }) 80 | }) 81 | 82 | describe('setAuth()', function () { 83 | var twit; 84 | 85 | beforeEach(function () { 86 | twit = new Twit({ 87 | consumer_key: 'a', 88 | consumer_secret: 'b', 89 | access_token: 'c', 90 | access_token_secret: 'd' 91 | }) 92 | }) 93 | 94 | it('should update the client\'s auth config', function (done) { 95 | // partial update 96 | twit.setAuth({ 97 | consumer_key: 'x', 98 | consumer_secret: 'y' 99 | }) 100 | 101 | assert(twit.config.consumer_key === 'x') 102 | assert(twit.config.consumer_secret === 'y') 103 | 104 | // full update 105 | twit.setAuth(config1) 106 | 107 | assert(twit.config.consumer_key === config1.consumer_key) 108 | assert(twit.config.consumer_secret === config1.consumer_secret) 109 | assert(twit.config.access_token === config1.access_token) 110 | assert(twit.config.access_token_secret === config1.access_token_secret) 111 | 112 | twit.get('account/verify_credentials', { twit_options: { retry: true } }, function (err, reply, response) { 113 | assert(!err, err); 114 | assert(response.headers['x-rate-limit-limit']) 115 | done() 116 | }) 117 | }) 118 | }) 119 | }); 120 | -------------------------------------------------------------------------------- /examples/bot.js: -------------------------------------------------------------------------------- 1 | // 2 | // Bot 3 | // class for performing various twitter actions 4 | // 5 | var Twit = require('../lib/twitter'); 6 | 7 | var Bot = module.exports = function(config) { 8 | this.twit = new Twit(config); 9 | }; 10 | 11 | // 12 | // post a tweet 13 | // 14 | Bot.prototype.tweet = function (status, callback) { 15 | if(typeof status !== 'string') { 16 | return callback(new Error('tweet must be of type String')); 17 | } else if(status.length > 280) { 18 | return callback(new Error('tweet is too long: ' + status.length)); 19 | } 20 | this.twit.post('statuses/update', { status: status }, callback); 21 | }; 22 | 23 | // choose a random tweet and follow that user 24 | Bot.prototype.searchFollow = function (params, callback) { 25 | var self = this; 26 | 27 | self.twit.get('search/tweets', params, function (err, reply) { 28 | if(err) return callback(err); 29 | 30 | var tweets = reply.statuses; 31 | var rTweet = randIndex(tweets) 32 | if(typeof rTweet != 'undefined') 33 | { 34 | var target = rTweet.user.id_str; 35 | 36 | self.twit.post('friendships/create', { id: target }, callback); 37 | } 38 | }); 39 | }; 40 | 41 | // 42 | // retweet 43 | // 44 | Bot.prototype.retweet = function (params, callback) { 45 | var self = this; 46 | 47 | self.twit.get('search/tweets', params, function (err, reply) { 48 | if(err) return callback(err); 49 | 50 | var tweets = reply.statuses; 51 | var randomTweet = randIndex(tweets); 52 | if(typeof randomTweet != 'undefined') 53 | self.twit.post('statuses/retweet/:id', { id: randomTweet.id_str }, callback); 54 | }); 55 | }; 56 | 57 | // 58 | // favorite a tweet 59 | // 60 | Bot.prototype.favorite = function (params, callback) { 61 | var self = this; 62 | 63 | self.twit.get('search/tweets', params, function (err, reply) { 64 | if(err) return callback(err); 65 | 66 | var tweets = reply.statuses; 67 | var randomTweet = randIndex(tweets); 68 | if(typeof randomTweet != 'undefined') 69 | self.twit.post('favorites/create', { id: randomTweet.id_str }, callback); 70 | }); 71 | }; 72 | 73 | // 74 | 75 | // choose a random friend of one of your followers, and follow that user 76 | // 77 | Bot.prototype.mingle = function (callback) { 78 | var self = this; 79 | 80 | this.twit.get('followers/ids', function(err, reply) { 81 | if(err) { return callback(err); } 82 | 83 | var followers = reply.ids 84 | , randFollower = randIndex(followers); 85 | 86 | self.twit.get('friends/ids', { user_id: randFollower }, function(err, reply) { 87 | if(err) { return callback(err); } 88 | 89 | var friends = reply.ids 90 | , target = randIndex(friends); 91 | 92 | self.twit.post('friendships/create', { id: target }, callback); 93 | }) 94 | }) 95 | }; 96 | 97 | // 98 | // prune your followers list; unfollow a friend that hasn't followed you back 99 | // 100 | Bot.prototype.prune = function (callback) { 101 | var self = this; 102 | 103 | this.twit.get('followers/ids', function(err, reply) { 104 | if(err) return callback(err); 105 | 106 | var followers = reply.ids; 107 | 108 | self.twit.get('friends/ids', function(err, reply) { 109 | if(err) return callback(err); 110 | 111 | var friends = reply.ids 112 | , pruned = false; 113 | 114 | while(!pruned) { 115 | var target = randIndex(friends); 116 | 117 | if(!~followers.indexOf(target)) { 118 | pruned = true; 119 | self.twit.post('friendships/destroy', { id: target }, callback); 120 | } 121 | } 122 | }); 123 | }); 124 | }; 125 | 126 | function randIndex (arr) { 127 | var index = Math.floor(arr.length*Math.random()); 128 | return arr[index]; 129 | }; 130 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | var querystring = require('querystring'); 2 | var request = require('request'); 3 | 4 | var endpoints = require('./endpoints'); 5 | 6 | /** 7 | * Encodes object as a querystring, to be used as the suffix of request URLs. 8 | * @param {Object} obj 9 | * @return {String} 10 | */ 11 | exports.makeQueryString = function (obj) { 12 | var qs = querystring.stringify(obj) 13 | qs = qs.replace(/\!/g, "%21") 14 | .replace(/\'/g, "%27") 15 | .replace(/\(/g, "%28") 16 | .replace(/\)/g, "%29") 17 | .replace(/\*/g, "%2A"); 18 | return qs 19 | } 20 | 21 | /** 22 | * For each `/:param` fragment in path, move the value in params 23 | * at that key to path. If the key is not found in params, throw. 24 | * Modifies both params and path values. 25 | * 26 | * @param {Objet} params Object used to build path. 27 | * @param {String} path String to transform. 28 | * @return {Undefined} 29 | * 30 | */ 31 | exports.moveParamsIntoPath = function (params, path) { 32 | var rgxParam = /\/:(\w+)/g 33 | var missingParamErr = null 34 | 35 | path = path.replace(rgxParam, function (hit) { 36 | var paramName = hit.slice(2) 37 | var suppliedVal = params[paramName] 38 | if (!suppliedVal) { 39 | throw new Error('Twit: Params object is missing a required parameter for this request: `'+paramName+'`') 40 | } 41 | var retVal = '/' + suppliedVal 42 | delete params[paramName] 43 | return retVal 44 | }) 45 | return path 46 | } 47 | 48 | /** 49 | * When Twitter returns a response that looks like an error response, 50 | * use this function to attach the error info in the response body to `err`. 51 | * 52 | * @param {Error} err Error instance to which body info will be attached 53 | * @param {Object} body JSON object that is the deserialized HTTP response body received from Twitter 54 | * @return {Undefined} 55 | */ 56 | exports.attachBodyInfoToError = function (err, body) { 57 | err.twitterReply = body; 58 | if (!body) { 59 | return 60 | } 61 | if (body.error) { 62 | // the body itself is an error object 63 | err.message = body.error 64 | err.allErrors = err.allErrors.concat([body]) 65 | } else if (body.errors && body.errors.length) { 66 | // body contains multiple error objects 67 | err.message = body.errors[0].message; 68 | err.code = body.errors[0].code; 69 | err.allErrors = err.allErrors.concat(body.errors) 70 | } 71 | } 72 | 73 | exports.makeTwitError = function (message) { 74 | var err = new Error() 75 | if (message) { 76 | err.message = message 77 | } 78 | err.code = null 79 | err.allErrors = [] 80 | err.twitterReply = null 81 | return err 82 | } 83 | 84 | /** 85 | * Get a bearer token for OAuth2 86 | * @param {String} consumer_key 87 | * @param {String} consumer_secret 88 | * @param {Function} cb 89 | * 90 | * Calls `cb` with Error, String 91 | * 92 | * Error (if it exists) is guaranteed to be Twit error-formatted. 93 | * String (if it exists) is the bearer token received from Twitter. 94 | */ 95 | exports.getBearerToken = function (consumer_key, consumer_secret, cb) { 96 | // use OAuth 2 for app-only auth (Twitter requires this) 97 | // get a bearer token using our app's credentials 98 | var b64Credentials = new Buffer(consumer_key + ':' + consumer_secret).toString('base64'); 99 | request.post({ 100 | url: endpoints.API_HOST + 'oauth2/token', 101 | headers: { 102 | 'Authorization': 'Basic ' + b64Credentials, 103 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' 104 | }, 105 | body: 'grant_type=client_credentials', 106 | json: true, 107 | }, function (err, res, body) { 108 | if (err) { 109 | var error = exports.makeTwitError(err.toString()); 110 | exports.attachBodyInfoToError(error, body); 111 | return cb(error, body, res); 112 | } 113 | 114 | if ( !body ) { 115 | var error = exports.makeTwitError('Not valid reply from Twitter upon obtaining bearer token'); 116 | exports.attachBodyInfoToError(error, body); 117 | return cb(error, body, res); 118 | } 119 | 120 | if (body.token_type !== 'bearer') { 121 | var error = exports.makeTwitError('Unexpected reply from Twitter upon obtaining bearer token'); 122 | exports.attachBodyInfoToError(error, body); 123 | return cb(error, body, res); 124 | } 125 | 126 | return cb(err, body.access_token); 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /lib/file_uploader.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var fs = require('fs'); 3 | var mime = require('mime'); 4 | var util = require('util'); 5 | 6 | var MAX_FILE_SIZE_BYTES = 15 * 1024 * 1024; 7 | var MAX_FILE_CHUNK_BYTES = 5 * 1024 * 1024; 8 | 9 | /** 10 | * FileUploader class used to upload a file to twitter via the /media/upload (chunked) API. 11 | * Usage: 12 | * var fu = new FileUploader({ file_path: '/foo/bar/baz.mp4' }, twit); 13 | * fu.upload(function (err, bodyObj, resp) { 14 | * console.log(err, bodyObj); 15 | * }) 16 | * 17 | * @param {Object} params Object of the form { file_path: String }. 18 | * @param {Twit(object)} twit Twit instance. 19 | */ 20 | var FileUploader = function (params, twit) { 21 | assert(params) 22 | assert(params.file_path, 'Must specify `file_path` to upload a file. Got: ' + params.file_path + '.') 23 | var self = this; 24 | self._file_path = params.file_path; 25 | self._twit = twit; 26 | self._isUploading = false; 27 | self._isFileStreamEnded = false; 28 | self._isSharedMedia = !!params.shared; 29 | } 30 | 31 | /** 32 | * Upload a file to Twitter via the /media/upload (chunked) API. 33 | * 34 | * @param {Function} cb function (err, data, resp) 35 | */ 36 | FileUploader.prototype.upload = function (cb) { 37 | var self = this; 38 | 39 | // Send INIT command with file info and get back a media_id_string we can use to APPEND chunks to it. 40 | self._initMedia(function (err, bodyObj, resp) { 41 | if (err) { 42 | cb(err); 43 | return; 44 | } else { 45 | var mediaTmpId = bodyObj.media_id_string; 46 | var chunkNumber = 0; 47 | var mediaFile = fs.createReadStream(self._file_path, { highWatermark: MAX_FILE_CHUNK_BYTES }); 48 | 49 | mediaFile.on('data', function (chunk) { 50 | // Pause our file stream from emitting `data` events until the upload of this chunk completes. 51 | // Any data that becomes available will remain in the internal buffer. 52 | mediaFile.pause(); 53 | self._isUploading = true; 54 | 55 | self._appendMedia(mediaTmpId, chunk.toString('base64'), chunkNumber, function (err, bodyObj, resp) { 56 | self._isUploading = false; 57 | if (err) { 58 | cb(err); 59 | } else { 60 | if (self._isUploadComplete()) { 61 | // We've hit the end of our stream; send FINALIZE command. 62 | self._finalizeMedia(mediaTmpId, cb); 63 | } else { 64 | // Tell our file stream to start emitting `data` events again. 65 | chunkNumber++; 66 | mediaFile.resume(); 67 | } 68 | } 69 | }); 70 | }); 71 | 72 | mediaFile.on('end', function () { 73 | // Mark our file streaming complete, and if done, send FINALIZE command. 74 | self._isFileStreamEnded = true; 75 | if (self._isUploadComplete()) { 76 | self._finalizeMedia(mediaTmpId, cb); 77 | } 78 | }); 79 | } 80 | }) 81 | } 82 | 83 | FileUploader.prototype._isUploadComplete = function () { 84 | return !this._isUploading && this._isFileStreamEnded; 85 | } 86 | 87 | /** 88 | * Send FINALIZE command for media object with id `media_id`. 89 | * 90 | * @param {String} media_id 91 | * @param {Function} cb 92 | */ 93 | FileUploader.prototype._finalizeMedia = function(media_id, cb) { 94 | var self = this; 95 | self._twit.post('media/upload', { 96 | command: 'FINALIZE', 97 | media_id: media_id 98 | }, cb); 99 | } 100 | 101 | /** 102 | * Send APPEND command for media object with id `media_id`. 103 | * Append the chunk to the media object, then resume streaming our mediaFile. 104 | * 105 | * @param {String} media_id media_id_string received from Twitter after sending INIT comand. 106 | * @param {String} chunk_part Base64-encoded String chunk of the media file. 107 | * @param {Number} segment_index Index of the segment. 108 | * @param {Function} cb 109 | */ 110 | FileUploader.prototype._appendMedia = function(media_id_string, chunk_part, segment_index, cb) { 111 | var self = this; 112 | self._twit.post('media/upload', { 113 | command: 'APPEND', 114 | media_id: media_id_string.toString(), 115 | segment_index: segment_index, 116 | media: chunk_part, 117 | }, cb); 118 | } 119 | 120 | /** 121 | * Send INIT command for our underlying media object. 122 | * 123 | * @param {Function} cb 124 | */ 125 | FileUploader.prototype._initMedia = function (cb) { 126 | var self = this; 127 | var mediaType = mime.lookup(self._file_path); 128 | var mediaFileSizeBytes = fs.statSync(self._file_path).size; 129 | var shared = self._isSharedMedia; 130 | var media_category = 'tweet_image'; 131 | 132 | if (mediaType.toLowerCase().indexOf('gif') > -1) { 133 | media_category = 'tweet_gif'; 134 | } else if (mediaType.toLowerCase().indexOf('video') > -1) { 135 | media_category = 'tweet_video'; 136 | } 137 | 138 | // Check the file size - it should not go over 15MB for video. 139 | // See https://dev.twitter.com/rest/reference/post/media/upload-chunked 140 | if (mediaFileSizeBytes < MAX_FILE_SIZE_BYTES) { 141 | self._twit.post('media/upload', { 142 | 'command': 'INIT', 143 | 'media_type': mediaType, 144 | 'total_bytes': mediaFileSizeBytes, 145 | 'shared': shared, 146 | 'media_category': media_category 147 | }, cb); 148 | } else { 149 | var errMsg = util.format('This file is too large. Max size is %dB. Got: %dB.', MAX_FILE_SIZE_BYTES, mediaFileSizeBytes); 150 | cb(new Error(errMsg)); 151 | } 152 | } 153 | 154 | module.exports = FileUploader 155 | -------------------------------------------------------------------------------- /lib/streaming-api-connection.js: -------------------------------------------------------------------------------- 1 | 2 | var EventEmitter = require('events').EventEmitter; 3 | var util = require('util'); 4 | 5 | var helpers = require('./helpers') 6 | var Parser = require('./parser'); 7 | var request = require('request'); 8 | 9 | var STATUS_CODES_TO_ABORT_ON = require('./settings').STATUS_CODES_TO_ABORT_ON 10 | 11 | var StreamingAPIConnection = function (reqOpts, twitOptions) { 12 | this.reqOpts = reqOpts 13 | this.twitOptions = twitOptions 14 | this._twitter_time_minus_local_time_ms = 0 15 | EventEmitter.call(this) 16 | } 17 | 18 | util.inherits(StreamingAPIConnection, EventEmitter) 19 | 20 | /** 21 | * Resets the connection. 22 | * - clears request, response, parser 23 | * - removes scheduled reconnect handle (if one was scheduled) 24 | * - stops the stall abort timeout handle (if one was scheduled) 25 | */ 26 | StreamingAPIConnection.prototype._resetConnection = function () { 27 | if (this.request) { 28 | // clear our reference to the `request` instance 29 | this.request.removeAllListeners(); 30 | this.request.destroy(); 31 | } 32 | 33 | if (this.response) { 34 | // clear our reference to the http.IncomingMessage instance 35 | this.response.removeAllListeners(); 36 | this.response.destroy(); 37 | } 38 | 39 | if (this.parser) { 40 | this.parser.removeAllListeners() 41 | } 42 | 43 | // ensure a scheduled reconnect does not occur (if one was scheduled) 44 | // this can happen if we get a close event before .stop() is called 45 | clearTimeout(this._scheduledReconnect) 46 | delete this._scheduledReconnect 47 | 48 | // clear our stall abort timeout 49 | this._stopStallAbortTimeout() 50 | } 51 | 52 | /** 53 | * Resets the parameters used in determining the next reconnect time 54 | */ 55 | StreamingAPIConnection.prototype._resetRetryParams = function () { 56 | // delay for next reconnection attempt 57 | this._connectInterval = 0 58 | // flag indicating whether we used a 0-delay reconnect 59 | this._usedFirstReconnect = false 60 | } 61 | 62 | StreamingAPIConnection.prototype._startPersistentConnection = function () { 63 | var self = this; 64 | self._resetConnection(); 65 | self._setupParser(); 66 | self._resetStallAbortTimeout(); 67 | self._setOauthTimestamp(); 68 | this.reqOpts.encoding = 'utf8' 69 | self.request = request.post(this.reqOpts); 70 | self.emit('connect', self.request); 71 | self.request.on('response', function (response) { 72 | self._updateOauthTimestampOffsetFromResponse(response) 73 | // reset our reconnection attempt flag so next attempt goes through with 0 delay 74 | // if we get a transport-level error 75 | self._usedFirstReconnect = false; 76 | // start a stall abort timeout handle 77 | self._resetStallAbortTimeout(); 78 | self.response = response 79 | if (STATUS_CODES_TO_ABORT_ON.indexOf(self.response.statusCode) !== -1) { 80 | // We got a status code telling us we should abort the connection. 81 | // Read the body from the response and return an error to the user. 82 | var body = ''; 83 | 84 | self.request.on('data', function (chunk) { 85 | body += chunk; 86 | }) 87 | 88 | self.request.on('end', function () { 89 | try { 90 | body = JSON.parse(body) 91 | } catch (jsonDecodeError) { 92 | // Twitter may send an HTML body 93 | // if non-JSON text was returned, we'll just attach it to the error as-is 94 | } 95 | // surface the error to the user 96 | var error = helpers.makeTwitError('Bad Twitter streaming request: ' + self.response.statusCode) 97 | error.statusCode = response ? response.statusCode: null; 98 | helpers.attachBodyInfoToError(error, body) 99 | self.emit('error', error); 100 | // stop the stream explicitly so we don't reconnect 101 | self.stop() 102 | body = null; 103 | }); 104 | self.request.on('error', function (err) { 105 | var twitErr = helpers.makeTwitError(err.message); 106 | twitErr.statusCode = self.response.statusCode; 107 | helpers.attachBodyInfoToError(twitErr, body); 108 | self.emit('parser-error', twitErr); 109 | }); 110 | } else if (self.response.statusCode === 420) { 111 | // close the connection forcibly so a reconnect is scheduled by `self.onClose()` 112 | self._scheduleReconnect(); 113 | } else { 114 | // We got an OK status code - the response should be valid. 115 | // Read the body from the response and return to the user. 116 | //pass all response data to parser 117 | self.request.on('data', function(data) { 118 | self._connectInterval = 0 119 | self._resetStallAbortTimeout(); 120 | self.parser.parse(data); 121 | }) 122 | 123 | self.response.on('close', self._onClose.bind(self)) 124 | self.response.on('error', function (err) { 125 | // expose response errors on twit instance 126 | self.emit('error', err); 127 | }) 128 | 129 | // connected without an error response from Twitter, emit `connected` event 130 | // this must be emitted after all its event handlers are bound 131 | // so the reference to `self.response` is not interfered-with by the user until it is emitted 132 | self.emit('connected', self.response); 133 | } 134 | }); 135 | self.request.on('close', self._onClose.bind(self)); 136 | self.request.on('error', function (err) { self._scheduleReconnect.bind(self) }); 137 | return self; 138 | } 139 | 140 | /** 141 | * Handle when the request or response closes. 142 | * Schedule a reconnect according to Twitter's reconnect guidelines 143 | * 144 | */ 145 | StreamingAPIConnection.prototype._onClose = function () { 146 | var self = this; 147 | self._stopStallAbortTimeout(); 148 | if (self._scheduledReconnect) { 149 | // if we already have a reconnect scheduled, don't schedule another one. 150 | // this race condition can happen if the http.ClientRequest and http.IncomingMessage both emit `close` 151 | return 152 | } 153 | 154 | self._scheduleReconnect(); 155 | } 156 | 157 | /** 158 | * Kick off the http request, and persist the connection 159 | * 160 | */ 161 | StreamingAPIConnection.prototype.start = function () { 162 | this._resetRetryParams(); 163 | this._startPersistentConnection(); 164 | return this; 165 | } 166 | 167 | /** 168 | * Abort the http request, stop scheduled reconnect (if one was scheduled) and clear state 169 | * 170 | */ 171 | StreamingAPIConnection.prototype.stop = function () { 172 | // clear connection variables and timeout handles 173 | this._resetConnection(); 174 | this._resetRetryParams(); 175 | return this; 176 | } 177 | 178 | /** 179 | * Stop and restart the stall abort timer (called when new data is received) 180 | * 181 | * If we go 90s without receiving data from twitter, we abort the request & reconnect. 182 | */ 183 | StreamingAPIConnection.prototype._resetStallAbortTimeout = function () { 184 | var self = this; 185 | // stop the previous stall abort timer 186 | self._stopStallAbortTimeout(); 187 | //start a new 90s timeout to trigger a close & reconnect if no data received 188 | self._stallAbortTimeout = setTimeout(function () { 189 | self._scheduleReconnect() 190 | }, 90000); 191 | return this; 192 | } 193 | 194 | /** 195 | * Stop stall timeout 196 | * 197 | */ 198 | StreamingAPIConnection.prototype._stopStallAbortTimeout = function () { 199 | clearTimeout(this._stallAbortTimeout); 200 | // mark the timer as `null` so it is clear via introspection that the timeout is not scheduled 201 | delete this._stallAbortTimeout; 202 | return this; 203 | } 204 | 205 | /** 206 | * Computes the next time a reconnect should occur (based on the last HTTP response received) 207 | * and starts a timeout handle to begin reconnecting after `self._connectInterval` passes. 208 | * 209 | * @return {Undefined} 210 | */ 211 | StreamingAPIConnection.prototype._scheduleReconnect = function () { 212 | var self = this; 213 | if (self.response && self.response.statusCode === 420) { 214 | // we are being rate limited 215 | // start with a 1 minute wait and double each attempt 216 | if (!self._connectInterval) { 217 | self._connectInterval = 60000; 218 | } else { 219 | self._connectInterval *= 2; 220 | } 221 | } else if (self.response && String(self.response.statusCode).charAt(0) === '5') { 222 | // twitter 5xx errors 223 | // start with a 5s wait, double each attempt up to 320s 224 | if (!self._connectInterval) { 225 | self._connectInterval = 5000; 226 | } else if (self._connectInterval < 320000) { 227 | self._connectInterval *= 2; 228 | } else { 229 | self._connectInterval = 320000; 230 | } 231 | } else { 232 | // we did not get an HTTP response from our last connection attempt. 233 | // DNS/TCP error, or a stall in the stream (and stall timer closed the connection) 234 | if (!self._usedFirstReconnect) { 235 | // first reconnection attempt on a valid connection should occur immediately 236 | self._connectInterval = 0; 237 | self._usedFirstReconnect = true; 238 | } else if (self._connectInterval < 16000) { 239 | // linearly increase delay by 250ms up to 16s 240 | self._connectInterval += 250; 241 | } else { 242 | // cap out reconnect interval at 16s 243 | self._connectInterval = 16000; 244 | } 245 | } 246 | 247 | // schedule the reconnect 248 | self._scheduledReconnect = setTimeout(function () { 249 | self._startPersistentConnection(); 250 | }, self._connectInterval); 251 | self.emit('reconnect', self.request, self.response, self._connectInterval); 252 | } 253 | 254 | StreamingAPIConnection.prototype._setupParser = function () { 255 | var self = this 256 | self.parser = new Parser() 257 | 258 | // handle twitter objects as they come in - emit the generic `message` event 259 | // along with the specific event corresponding to the message 260 | self.parser.on('element', function (msg) { 261 | self.emit('message', msg) 262 | 263 | if (msg.delete) { self.emit('delete', msg) } 264 | else if (msg.disconnect) { self._handleDisconnect(msg) } 265 | else if (msg.limit) { self.emit('limit', msg) } 266 | else if (msg.scrub_geo) { self.emit('scrub_geo', msg) } 267 | else if (msg.warning) { self.emit('warning', msg) } 268 | else if (msg.status_withheld) { self.emit('status_withheld', msg) } 269 | else if (msg.user_withheld) { self.emit('user_withheld', msg) } 270 | else if (msg.friends || msg.friends_str) { self.emit('friends', msg) } 271 | else if (msg.direct_message) { self.emit('direct_message', msg) } 272 | else if (msg.event) { 273 | self.emit('user_event', msg) 274 | // reference: https://dev.twitter.com/docs/streaming-apis/messages#User_stream_messages 275 | var ev = msg.event 276 | 277 | if (ev === 'blocked') { self.emit('blocked', msg) } 278 | else if (ev === 'unblocked') { self.emit('unblocked', msg) } 279 | else if (ev === 'favorite') { self.emit('favorite', msg) } 280 | else if (ev === 'unfavorite') { self.emit('unfavorite', msg) } 281 | else if (ev === 'follow') { self.emit('follow', msg) } 282 | else if (ev === 'unfollow') { self.emit('unfollow', msg) } 283 | else if (ev === 'mute') { self.emit('mute', msg) } 284 | else if (ev === 'unmute') { self.emit('unmute', msg) } 285 | else if (ev === 'user_update') { self.emit('user_update', msg) } 286 | else if (ev === 'list_created') { self.emit('list_created', msg) } 287 | else if (ev === 'list_destroyed') { self.emit('list_destroyed', msg) } 288 | else if (ev === 'list_updated') { self.emit('list_updated', msg) } 289 | else if (ev === 'list_member_added') { self.emit('list_member_added', msg) } 290 | else if (ev === 'list_member_removed') { self.emit('list_member_removed', msg) } 291 | else if (ev === 'list_user_subscribed') { self.emit('list_user_subscribed', msg) } 292 | else if (ev === 'list_user_unsubscribed') { self.emit('list_user_unsubscribed', msg) } 293 | else if (ev === 'quoted_tweet') { self.emit('quoted_tweet', msg) } 294 | else if (ev === 'favorited_retweet') { self.emit('favorited_retweet', msg) } 295 | else if (ev === 'retweeted_retweet') { self.emit('retweeted_retweet', msg) } 296 | else { self.emit('unknown_user_event', msg) } 297 | } else { self.emit('tweet', msg) } 298 | }) 299 | 300 | self.parser.on('error', function (err) { 301 | self.emit('parser-error', err) 302 | }); 303 | self.parser.on('connection-limit-exceeded', function (err) { 304 | self.emit('error', err); 305 | }) 306 | } 307 | 308 | StreamingAPIConnection.prototype._handleDisconnect = function (twitterMsg) { 309 | this.emit('disconnect', twitterMsg); 310 | this.stop(); 311 | } 312 | 313 | /** 314 | * Call whenever an http request is about to be made to update 315 | * our local timestamp (used for Oauth) to be Twitter's server time. 316 | * 317 | */ 318 | StreamingAPIConnection.prototype._setOauthTimestamp = function () { 319 | var self = this; 320 | if (self.reqOpts.oauth) { 321 | var oauth_ts = Date.now() + self._twitter_time_minus_local_time_ms; 322 | self.reqOpts.oauth.timestamp = Math.floor(oauth_ts/1000).toString(); 323 | } 324 | } 325 | 326 | /** 327 | * Call whenever an http response is received from Twitter, 328 | * to set our local timestamp offset from Twitter's server time. 329 | * This is used to set the Oauth timestamp for our next http request 330 | * to Twitter (by calling _setOauthTimestamp). 331 | * 332 | * @param {http.IncomingResponse} resp http response received from Twitter. 333 | */ 334 | StreamingAPIConnection.prototype._updateOauthTimestampOffsetFromResponse = function (resp) { 335 | if (resp && resp.headers && resp.headers.date && 336 | new Date(resp.headers.date).toString() !== 'Invalid Date' 337 | ) { 338 | var twitterTimeMs = new Date(resp.headers.date).getTime() 339 | this._twitter_time_minus_local_time_ms = twitterTimeMs - Date.now(); 340 | } 341 | } 342 | 343 | module.exports = StreamingAPIConnection 344 | -------------------------------------------------------------------------------- /lib/twitter.js: -------------------------------------------------------------------------------- 1 | // 2 | // Twitter API Wrapper 3 | // 4 | var assert = require('assert'); 5 | var Promise = require('bluebird'); 6 | var request = require('request'); 7 | var util = require('util'); 8 | var endpoints = require('./endpoints'); 9 | var FileUploader = require('./file_uploader'); 10 | var helpers = require('./helpers'); 11 | var StreamingAPIConnection = require('./streaming-api-connection'); 12 | var STATUS_CODES_TO_ABORT_ON = require('./settings').STATUS_CODES_TO_ABORT_ON; 13 | 14 | // config values required for app-only auth 15 | var required_for_app_auth = [ 16 | 'consumer_key', 17 | 'consumer_secret' 18 | ]; 19 | 20 | // config values required for user auth (superset of app-only auth) 21 | var required_for_user_auth = required_for_app_auth.concat([ 22 | 'access_token', 23 | 'access_token_secret' 24 | ]); 25 | 26 | var FORMDATA_PATHS = [ 27 | 'media/upload', 28 | 'account/update_profile_image', 29 | 'account/update_profile_background_image', 30 | ]; 31 | 32 | var JSONPAYLOAD_PATHS = [ 33 | 'media/metadata/create', 34 | 'direct_messages/events/new', 35 | 'direct_messages/welcome_messages/new', 36 | 'direct_messages/welcome_messages/rules/new', 37 | ]; 38 | 39 | // 40 | // Twitter 41 | // 42 | var Twitter = function (config) { 43 | if (!(this instanceof Twitter)) { 44 | return new Twitter(config); 45 | } 46 | 47 | var self = this 48 | var credentials = { 49 | consumer_key : config.consumer_key, 50 | consumer_secret : config.consumer_secret, 51 | // access_token and access_token_secret only required for user auth 52 | access_token : config.access_token, 53 | access_token_secret : config.access_token_secret, 54 | // flag indicating whether requests should be made with application-only auth 55 | app_only_auth : config.app_only_auth, 56 | } 57 | 58 | this._validateConfigOrThrow(config); 59 | this.config = config; 60 | this._twitter_time_minus_local_time_ms = 0; 61 | } 62 | 63 | Twitter.prototype.get = function (path, params, callback) { 64 | return this.request('GET', path, params, callback) 65 | } 66 | 67 | Twitter.prototype.post = function (path, params, callback) { 68 | return this.request('POST', path, params, callback) 69 | } 70 | 71 | Twitter.prototype.delete = function (path, params, callback) { 72 | return this.request('DELETE', path, params, callback) 73 | } 74 | 75 | Twitter.prototype.request = function (method, path, params, callback) { 76 | var self = this; 77 | assert(method == 'GET' || method == 'POST' || method == 'DELETE'); 78 | // if no `params` is specified but a callback is, use default params 79 | if (typeof params === 'function') { 80 | callback = params 81 | params = {} 82 | } 83 | 84 | return new Promise(function (resolve, reject) { 85 | var _returnErrorToUser = function (err) { 86 | if (callback && typeof callback === 'function') { 87 | callback(err, null, null); 88 | } else { 89 | reject(err); 90 | } 91 | } 92 | 93 | self._buildReqOpts(method, path, params, false, function (err, reqOpts) { 94 | if (err) { 95 | _returnErrorToUser(err); 96 | return 97 | } 98 | 99 | var twitOptions = (params && params.twit_options) || {}; 100 | 101 | process.nextTick(function () { 102 | // ensure all HTTP i/o occurs after the user has a chance to bind their event handlers 103 | self._doRestApiRequest(reqOpts, twitOptions, method, function (err, parsedBody, resp) { 104 | self._updateClockOffsetFromResponse(resp); 105 | var peerCertificate = resp && resp.socket && resp.socket.getPeerCertificate(); 106 | 107 | if (self.config.trusted_cert_fingerprints && peerCertificate) { 108 | if (!resp.socket.authorized) { 109 | // The peer certificate was not signed by one of the authorized CA's. 110 | var authErrMsg = resp.socket.authorizationError.toString(); 111 | var err = helpers.makeTwitError('The peer certificate was not signed; ' + authErrMsg); 112 | _returnErrorToUser(err); 113 | return; 114 | } 115 | var fingerprint = peerCertificate.fingerprint; 116 | var trustedFingerprints = self.config.trusted_cert_fingerprints; 117 | if (trustedFingerprints.indexOf(fingerprint) === -1) { 118 | var errMsg = util.format('Certificate untrusted. Trusted fingerprints are: %s. Got fingerprint: %s.', 119 | trustedFingerprints.join(','), fingerprint); 120 | var err = new Error(errMsg); 121 | _returnErrorToUser(err); 122 | return; 123 | } 124 | } 125 | 126 | if (callback && typeof callback === 'function') { 127 | callback(err, parsedBody, resp); 128 | } else { 129 | if (err) { 130 | reject(err) 131 | } else { 132 | resolve({ data: parsedBody, resp: resp }); 133 | } 134 | } 135 | 136 | return; 137 | }) 138 | }) 139 | }); 140 | }); 141 | } 142 | 143 | /** 144 | * Uploads a file to Twitter via the POST media/upload (chunked) API. 145 | * Use this as an easier alternative to doing the INIT/APPEND/FINALIZE commands yourself. 146 | * Returns the response from the FINALIZE command, or if an error occurs along the way, 147 | * the first argument to `cb` will be populated with a non-null Error. 148 | * 149 | * 150 | * `params` is an Object of the form: 151 | * { 152 | * file_path: String // Absolute path of file to be uploaded. 153 | * } 154 | * 155 | * @param {Object} params options object (described above). 156 | * @param {cb} cb callback of the form: function (err, bodyObj, resp) 157 | */ 158 | Twitter.prototype.postMediaChunked = function (params, cb) { 159 | var self = this; 160 | try { 161 | var fileUploader = new FileUploader(params, self); 162 | } catch(err) { 163 | cb(err); 164 | return; 165 | } 166 | fileUploader.upload(cb); 167 | } 168 | 169 | Twitter.prototype._updateClockOffsetFromResponse = function (resp) { 170 | var self = this; 171 | if (resp && resp.headers && resp.headers.date && 172 | new Date(resp.headers.date).toString() !== 'Invalid Date' 173 | ) { 174 | var twitterTimeMs = new Date(resp.headers.date).getTime() 175 | self._twitter_time_minus_local_time_ms = twitterTimeMs - Date.now(); 176 | } 177 | } 178 | 179 | /** 180 | * Builds and returns an options object ready to pass to `request()` 181 | * @param {String} method "GET" or "POST" 182 | * @param {String} path REST API resource uri (eg. "statuses/destroy/:id") 183 | * @param {Object} params user's params object 184 | * @param {Boolean} isStreaming Flag indicating if it's a request to the Streaming API (different endpoint) 185 | * @returns {Undefined} 186 | * 187 | * Calls `callback` with Error, Object where Object is an options object ready to pass to `request()`. 188 | * 189 | * Returns error raised (if any) by `helpers.moveParamsIntoPath()` 190 | */ 191 | Twitter.prototype._buildReqOpts = function (method, path, params, isStreaming, callback) { 192 | var self = this 193 | if (!params) { 194 | params = {} 195 | } 196 | // clone `params` object so we can modify it without modifying the user's reference 197 | var paramsClone = JSON.parse(JSON.stringify(params)) 198 | // convert any arrays in `paramsClone` to comma-seperated strings 199 | var finalParams = this.normalizeParams(paramsClone) 200 | delete finalParams.twit_options 201 | 202 | // the options object passed to `request` used to perform the HTTP request 203 | var reqOpts = { 204 | headers: { 205 | 'Accept': '*/*', 206 | 'User-Agent': 'twit-client' 207 | }, 208 | gzip: true, 209 | encoding: null, 210 | } 211 | 212 | if (typeof self.config.timeout_ms !== 'undefined' && !isStreaming) { 213 | reqOpts.timeout = self.config.timeout_ms; 214 | } 215 | 216 | if (typeof self.config.strictSSL !== 'undefined') { 217 | reqOpts.strictSSL = self.config.strictSSL; 218 | } 219 | 220 | // finalize the `path` value by building it using user-supplied params 221 | // when json parameters should not be in the payload 222 | if (JSONPAYLOAD_PATHS.indexOf(path) === -1) { 223 | try { 224 | path = helpers.moveParamsIntoPath(finalParams, path) 225 | } catch (e) { 226 | callback(e, null, null) 227 | return 228 | } 229 | } 230 | 231 | if (path.match(/^https?:\/\//i)) { 232 | // This is a full url request 233 | reqOpts.url = path 234 | } else 235 | if (isStreaming) { 236 | // This is a Streaming API request. 237 | 238 | var stream_endpoint_map = { 239 | user: endpoints.USER_STREAM, 240 | site: endpoints.SITE_STREAM 241 | } 242 | var endpoint = stream_endpoint_map[path] || endpoints.PUB_STREAM 243 | reqOpts.url = endpoint + path + '.json' 244 | } else { 245 | // This is a REST API request. 246 | 247 | if (path.indexOf('media/') !== -1) { 248 | // For media/upload, use a different endpoint. 249 | reqOpts.url = endpoints.MEDIA_UPLOAD + path + '.json'; 250 | } else { 251 | reqOpts.url = endpoints.REST_ROOT + path + '.json'; 252 | } 253 | 254 | if (FORMDATA_PATHS.indexOf(path) !== -1) { 255 | reqOpts.headers['Content-type'] = 'multipart/form-data'; 256 | reqOpts.form = finalParams; 257 | // set finalParams to empty object so we don't append a query string 258 | // of the params 259 | finalParams = {}; 260 | } else if (JSONPAYLOAD_PATHS.indexOf(path) !== -1) { 261 | reqOpts.headers['Content-type'] = 'application/json'; 262 | reqOpts.json = true; 263 | reqOpts.body = finalParams; 264 | // as above, to avoid appending query string for body params 265 | finalParams = {}; 266 | } else { 267 | reqOpts.headers['Content-type'] = 'application/json'; 268 | } 269 | } 270 | 271 | if (isStreaming) { 272 | reqOpts.form = finalParams 273 | } else if (Object.keys(finalParams).length) { 274 | // not all of the user's parameters were used to build the request path 275 | // add them as a query string 276 | var qs = helpers.makeQueryString(finalParams) 277 | reqOpts.url += '?' + qs 278 | } 279 | 280 | if (!self.config.app_only_auth) { 281 | // with user auth, we can just pass an oauth object to requests 282 | // to have the request signed 283 | var oauth_ts = Date.now() + self._twitter_time_minus_local_time_ms; 284 | 285 | reqOpts.oauth = { 286 | consumer_key: self.config.consumer_key, 287 | consumer_secret: self.config.consumer_secret, 288 | token: self.config.access_token, 289 | token_secret: self.config.access_token_secret, 290 | timestamp: Math.floor(oauth_ts/1000).toString(), 291 | } 292 | 293 | callback(null, reqOpts); 294 | return; 295 | } else { 296 | // we're using app-only auth, so we need to ensure we have a bearer token 297 | // Once we have a bearer token, add the Authorization header and return the fully qualified `reqOpts`. 298 | self._getBearerToken(function (err, bearerToken) { 299 | if (err) { 300 | callback(err, null) 301 | return 302 | } 303 | 304 | reqOpts.headers['Authorization'] = 'Bearer ' + bearerToken; 305 | callback(null, reqOpts) 306 | return 307 | }) 308 | } 309 | } 310 | 311 | /** 312 | * Make HTTP request to Twitter REST API. 313 | * @param {Object} reqOpts options object passed to `request()` 314 | * @param {Object} twitOptions 315 | * @param {String} method "GET" or "POST" 316 | * @param {Function} callback user's callback 317 | * @return {Undefined} 318 | */ 319 | Twitter.prototype._doRestApiRequest = function (reqOpts, twitOptions, method, callback) { 320 | var request_method = request[method.toLowerCase()]; 321 | var req = request_method(reqOpts); 322 | 323 | var body = ''; 324 | var response = null; 325 | 326 | var onRequestComplete = function () { 327 | if (body !== '') { 328 | try { 329 | body = JSON.parse(body) 330 | } catch (jsonDecodeError) { 331 | // there was no transport-level error, but a JSON object could not be decoded from the request body 332 | // surface this to the caller 333 | var err = helpers.makeTwitError('JSON decode error: Twitter HTTP response body was not valid JSON') 334 | err.statusCode = response ? response.statusCode: null; 335 | err.allErrors.concat({error: jsonDecodeError.toString()}) 336 | callback(err, body, response); 337 | return 338 | } 339 | } 340 | 341 | if (typeof body === 'object' && (body.error || body.errors)) { 342 | // we got a Twitter API-level error response 343 | // place the errors in the HTTP response body into the Error object and pass control to caller 344 | var err = helpers.makeTwitError('Twitter API Error') 345 | err.statusCode = response ? response.statusCode: null; 346 | helpers.attachBodyInfoToError(err, body); 347 | callback(err, body, response); 348 | return 349 | } 350 | 351 | // success case - no errors in HTTP response body 352 | callback(err, body, response) 353 | } 354 | 355 | req.on('response', function (res) { 356 | response = res 357 | // read data from `request` object which contains the decompressed HTTP response body, 358 | // `response` is the unmodified http.IncomingMessage object which may contain compressed data 359 | req.on('data', function (chunk) { 360 | body += chunk.toString('utf8') 361 | }) 362 | // we're done reading the response 363 | req.on('end', function () { 364 | onRequestComplete() 365 | }) 366 | }) 367 | 368 | req.on('error', function (err) { 369 | // transport-level error occurred - likely a socket error 370 | if (twitOptions.retry && 371 | STATUS_CODES_TO_ABORT_ON.indexOf(err.statusCode) !== -1 372 | ) { 373 | // retry the request since retries were specified and we got a status code we should retry on 374 | self.request(method, path, params, callback); 375 | return; 376 | } else { 377 | // pass the transport-level error to the caller 378 | err.statusCode = null 379 | err.code = null 380 | err.allErrors = []; 381 | helpers.attachBodyInfoToError(err, body) 382 | callback(err, body, response); 383 | return; 384 | } 385 | }) 386 | } 387 | 388 | /** 389 | * Creates/starts a connection object that stays connected to Twitter's servers 390 | * using Twitter's rules. 391 | * 392 | * @param {String} path Resource path to connect to (eg. "statuses/sample") 393 | * @param {Object} params user's params object 394 | * @return {StreamingAPIConnection} [description] 395 | */ 396 | Twitter.prototype.stream = function (path, params) { 397 | var self = this; 398 | var twitOptions = (params && params.twit_options) || {}; 399 | 400 | var streamingConnection = new StreamingAPIConnection() 401 | self._buildReqOpts('POST', path, params, true, function (err, reqOpts) { 402 | if (err) { 403 | // we can get an error if we fail to obtain a bearer token or construct reqOpts 404 | // surface this on the streamingConnection instance (where a user may register their error handler) 405 | streamingConnection.emit('error', err) 406 | return 407 | } 408 | // set the properties required to start the connection 409 | streamingConnection.reqOpts = reqOpts 410 | streamingConnection.twitOptions = twitOptions 411 | 412 | process.nextTick(function () { 413 | streamingConnection.start() 414 | }) 415 | }) 416 | 417 | return streamingConnection 418 | } 419 | 420 | /** 421 | * Gets bearer token from cached reference on `self`, or fetches a new one and sets it on `self`. 422 | * 423 | * @param {Function} callback Function to invoke with (Error, bearerToken) 424 | * @return {Undefined} 425 | */ 426 | Twitter.prototype._getBearerToken = function (callback) { 427 | var self = this; 428 | if (self._bearerToken) { 429 | return callback(null, self._bearerToken) 430 | } 431 | 432 | helpers.getBearerToken(self.config.consumer_key, self.config.consumer_secret, 433 | function (err, bearerToken) { 434 | if (err) { 435 | // return the fully-qualified Twit Error object to caller 436 | callback(err, null); 437 | return; 438 | } 439 | self._bearerToken = bearerToken; 440 | callback(null, self._bearerToken); 441 | return; 442 | }) 443 | } 444 | 445 | Twitter.prototype.normalizeParams = function (params) { 446 | var normalized = params 447 | if (params && typeof params === 'object') { 448 | Object.keys(params).forEach(function (key) { 449 | var value = params[key] 450 | // replace any arrays in `params` with comma-separated string 451 | if (Array.isArray(value)) 452 | normalized[key] = value.join(',') 453 | }) 454 | } else if (!params) { 455 | normalized = {} 456 | } 457 | return normalized 458 | } 459 | 460 | Twitter.prototype.setAuth = function (auth) { 461 | var self = this 462 | var configKeys = [ 463 | 'consumer_key', 464 | 'consumer_secret', 465 | 'access_token', 466 | 'access_token_secret' 467 | ]; 468 | 469 | // update config 470 | configKeys.forEach(function (k) { 471 | if (auth[k]) { 472 | self.config[k] = auth[k] 473 | } 474 | }) 475 | this._validateConfigOrThrow(self.config); 476 | } 477 | 478 | Twitter.prototype.getAuth = function () { 479 | return this.config 480 | } 481 | 482 | // 483 | // Check that the required auth credentials are present in `config`. 484 | // @param {Object} config Object containing credentials for REST API auth 485 | // 486 | Twitter.prototype._validateConfigOrThrow = function (config) { 487 | //check config for proper format 488 | if (typeof config !== 'object') { 489 | throw new TypeError('config must be object, got ' + typeof config) 490 | } 491 | 492 | if (typeof config.timeout_ms !== 'undefined' && isNaN(Number(config.timeout_ms))) { 493 | throw new TypeError('Twit config `timeout_ms` must be a Number. Got: ' + config.timeout_ms + '.'); 494 | } 495 | 496 | if (typeof config.strictSSL !== 'undefined' && typeof config.strictSSL !== 'boolean') { 497 | throw new TypeError('Twit config `strictSSL` must be a Boolean. Got: ' + config.strictSSL + '.'); 498 | } 499 | 500 | if (config.app_only_auth) { 501 | var auth_type = 'app-only auth' 502 | var required_keys = required_for_app_auth 503 | } else { 504 | var auth_type = 'user auth' 505 | var required_keys = required_for_user_auth 506 | } 507 | 508 | required_keys.forEach(function (req_key) { 509 | if (!config[req_key]) { 510 | var err_msg = util.format('Twit config must include `%s` when using %s.', req_key, auth_type) 511 | throw new Error(err_msg) 512 | } 513 | }) 514 | } 515 | 516 | module.exports = Twitter 517 | -------------------------------------------------------------------------------- /tests/streaming.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , http = require('http') 3 | , EventEmitter = require('events').EventEmitter 4 | , rewire = require('rewire') 5 | , sinon = require('sinon') 6 | , Twit = require('../lib/twitter') 7 | , config1 = require('../config1') 8 | , config2 = require('../config2') 9 | , colors = require('colors') 10 | , helpers = require('./helpers') 11 | , util = require('util') 12 | , zlib = require('zlib') 13 | , async = require('async') 14 | , restTest = require('./rest'); 15 | 16 | /** 17 | * Stop the stream and check the tweet we got back. 18 | * Call @done on completion. 19 | * 20 | * @param {object} stream object returned by twit.stream() 21 | * @param {Function} done completion callback 22 | */ 23 | exports.checkStream = function (stream, done) { 24 | stream.on('connected', function () { 25 | console.log('\nconnected'.grey) 26 | }); 27 | 28 | stream.once('tweet', function (tweet) { 29 | stream.stop() 30 | assert.ok(tweet) 31 | assert.equal('string', typeof tweet.text) 32 | assert.equal('string', typeof tweet.id_str) 33 | 34 | console.log(('\ntweet: '+tweet.text).grey) 35 | 36 | done() 37 | }); 38 | 39 | stream.on('reconnecting', function (req, res, connectInterval) { 40 | console.log('Got disconnected. Scheduling reconnect! statusCode:', res.statusCode, 'connectInterval', connectInterval) 41 | }); 42 | 43 | stream.on('error', function (err) { 44 | console.log('Stream emitted an error', err) 45 | return done(err) 46 | }) 47 | } 48 | 49 | /** 50 | * Check the stream state is correctly set for a stopped stream. 51 | * 52 | * @param {object} stream object returned by twit.stream() 53 | */ 54 | exports.checkStreamStopState = function (stream) { 55 | assert.strictEqual(stream._connectInterval, 0) 56 | assert.strictEqual(stream._usedFirstReconnect, false) 57 | assert.strictEqual(stream._scheduledReconnect, undefined) 58 | assert.strictEqual(stream._stallAbortTimeout, undefined) 59 | } 60 | 61 | describe('Streaming API', function () { 62 | 63 | it('statuses/sample', function (done) { 64 | var twit = new Twit(config1); 65 | var stream = twit.stream('statuses/sample') 66 | 67 | exports.checkStream(stream, done) 68 | }) 69 | 70 | it('statuses/filter using `track`', function (done) { 71 | this.timeout(120000) 72 | var twit = new Twit(config2); 73 | var stream = twit.stream('statuses/filter', { track: 'fun' }) 74 | 75 | exports.checkStream(stream, done) 76 | }) 77 | 78 | it('statuses/filter using `locations` string', function (done) { 79 | var twit = new Twit(config1); 80 | var world = '-180,-90,180,90'; 81 | var stream = twit.stream('statuses/filter', { locations: world }) 82 | 83 | exports.checkStream(stream, done) 84 | }) 85 | 86 | it('statuses/filter using `locations` array for San Francisco and New York', function (done) { 87 | var twit = new Twit(config2); 88 | var params = { 89 | locations: [ '-122.75', '36.8', '121.75', '37.8', '-74', '40', '73', '41' ] 90 | } 91 | 92 | var stream = twit.stream('statuses/filter', params) 93 | 94 | exports.checkStream(stream, done) 95 | }) 96 | 97 | it('statuses/filter using `track` array', function (done) { 98 | var twit = new Twit(config1); 99 | var params = { 100 | track: [ 'twitter', ':)', 'fun' ] 101 | } 102 | 103 | var stream = twit.stream('statuses/filter', params) 104 | 105 | exports.checkStream(stream, done) 106 | }) 107 | 108 | it('statuses/filter using `track` and `language`', function (done) { 109 | var twit = new Twit(config1); 110 | var params = { 111 | track: [ 'twitter', '#apple', 'google', 'twitter', 'facebook', 'happy', 'party', ':)' ], 112 | language: 'en' 113 | } 114 | 115 | var stream = twit.stream('statuses/filter', params) 116 | 117 | exports.checkStream(stream, done) 118 | }) 119 | 120 | it('stopping & restarting the stream works', function (done) { 121 | var twit = new Twit(config2); 122 | var stream = twit.stream('statuses/sample') 123 | 124 | //stop the stream after 2 seconds 125 | setTimeout(function () { 126 | stream.stop() 127 | 128 | exports.checkStreamStopState(stream) 129 | 130 | console.log('\nstopped stream') 131 | }, 2000) 132 | 133 | //after 3 seconds, start the stream, and stop after 'connect' 134 | setTimeout(function () { 135 | stream.once('connected', function (req) { 136 | console.log('\nrestarted stream') 137 | stream.stop() 138 | 139 | exports.checkStreamStopState(stream) 140 | 141 | console.log('\nstopped stream') 142 | done() 143 | }) 144 | 145 | //restart the stream 146 | stream.start() 147 | }, 3000) 148 | }) 149 | 150 | it('stopping & restarting stream emits to previously assigned callbacks', function (done) { 151 | var twit = new Twit(config1); 152 | var stream = twit.stream('statuses/sample') 153 | 154 | var started = false 155 | var numTweets = 0 156 | stream.on('tweet', function (tweet) { 157 | process.stdout.write('.') 158 | if (!started) { 159 | started = true 160 | numTweets++ 161 | console.log('received tweet', numTweets) 162 | 163 | console.log('stopping stream') 164 | stream.stop() 165 | 166 | exports.checkStreamStopState(stream) 167 | 168 | // we've successfully received a new tweet after restarting, test successful 169 | if (numTweets === 2) { 170 | done() 171 | } else { 172 | started = false 173 | console.log('restarting stream') 174 | 175 | setTimeout(function () { 176 | stream.start() 177 | }, 1000) 178 | } 179 | } 180 | }) 181 | 182 | stream.on('limit', function (limitMsg) { 183 | console.log('limit', limitMsg) 184 | }) 185 | 186 | stream.on('disconnect', function (disconnMsg) { 187 | console.log('disconnect', disconnMsg) 188 | }) 189 | 190 | stream.on('reconnect', function (req, res, ival) { 191 | console.log('reconnect. statusCode:', res.statusCode, 'interval:', ival) 192 | }) 193 | 194 | stream.on('connect', function (req) { 195 | console.log('connect') 196 | }) 197 | 198 | }) 199 | }) 200 | 201 | describe('streaming API direct message events', function () { 202 | var senderScreenName; 203 | var receiverScreenName; 204 | var twitSender; 205 | var twitReceiver; 206 | 207 | // before we send direct messages the user receiving the DM 208 | // has to follow the sender. Make this so. 209 | before(function (done) { 210 | twitSender = new Twit(config1); 211 | twitReceiver = new Twit(config2); 212 | 213 | // get sender/receiver names in parallel, then make the receiver follow the sender 214 | async.parallel({ 215 | // get sender screen name and set it for tests to use 216 | getSenderScreenName: function (parNext) { 217 | console.log('getting sender user screen_name') 218 | 219 | twitSender.get('account/verify_credentials', { twit_options: { retry: true } }, function (err, reply) { 220 | assert(!err, err) 221 | 222 | assert(reply) 223 | assert(reply.screen_name) 224 | 225 | senderScreenName = reply.screen_name 226 | 227 | return parNext() 228 | }) 229 | }, 230 | // get receiver screen name and set it for tests to use 231 | getReceiverScreenName: function (parNext) { 232 | console.log('getting receiver user screen_name') 233 | twitReceiver.get('account/verify_credentials', { twit_options: { retry: true } }, function (err, reply) { 234 | assert(!err, err) 235 | 236 | assert(reply) 237 | assert(reply.screen_name) 238 | 239 | receiverScreenName = reply.screen_name 240 | 241 | return parNext() 242 | }) 243 | } 244 | }, function (err) { 245 | assert(!err, err) 246 | 247 | var followParams = { screen_name: senderScreenName } 248 | console.log('making receiver user follow the sender user') 249 | // make receiver follow sender 250 | twitReceiver.post('friendships/create', followParams, function (err, reply) { 251 | assert(!err, err) 252 | assert(reply.following) 253 | 254 | done() 255 | }) 256 | }) 257 | }) 258 | 259 | it('user_stream `direct_message` event', function (done) { 260 | // User A follows User B 261 | // User A connects to their user stream 262 | // User B posts a DM to User A 263 | // User A receives it in their user stream 264 | this.timeout(0); 265 | 266 | // build out DM params 267 | function makeDmParams () { 268 | return { 269 | screen_name: receiverScreenName, 270 | text: helpers.generateRandomString(10) + ' direct message streaming event test! :-) ' + helpers.generateRandomString(20), 271 | twit_options: { 272 | retry: true 273 | } 274 | } 275 | } 276 | 277 | var dmIdsReceived = [] 278 | var dmIdsSent = [] 279 | var sentDmFound = false 280 | 281 | // start listening for user stream events 282 | var receiverStream = twitReceiver.stream('user') 283 | 284 | console.log('\nlistening for DMs') 285 | // listen for direct_message event and check DM once it's received 286 | receiverStream.on('direct_message', function (directMsg) { 287 | if (sentDmFound) { 288 | // don't call `done` more than once 289 | return 290 | } 291 | 292 | console.log('got DM event. id:', directMsg.direct_message.id_str) 293 | restTest.checkDm(directMsg.direct_message) 294 | dmIdsReceived.push(directMsg.direct_message.id_str) 295 | 296 | // make sure one of the DMs sent was found 297 | // (we can send multiple DMs if our stream has to reconnect) 298 | sentDmFound = dmIdsSent.some(function (dmId) { 299 | return dmId == directMsg.direct_message.id_str 300 | }) 301 | 302 | if (!sentDmFound) { 303 | console.log('this DM doesnt match our test DMs - still waiting for a matching one.') 304 | console.log('dmIdsSent', dmIdsSent) 305 | return 306 | } 307 | 308 | receiverStream.stop() 309 | return done() 310 | }) 311 | 312 | var lastTimeSent = 0 313 | var msToWait = 0 314 | var postDmInterval = null 315 | 316 | receiverStream.on('connected', function () { 317 | var dmParams = makeDmParams() 318 | 319 | console.log('sending a new DM:', dmParams.text) 320 | twitSender.post('direct_messages/new', dmParams, function (err, reply) { 321 | assert(!err, err) 322 | assert(reply) 323 | restTest.checkDm(reply) 324 | assert(reply.id_str) 325 | // we will check this dm against the reply recieved in the message event 326 | dmIdsSent.push(reply.id_str) 327 | 328 | console.log('successfully posted DM:', reply.text, reply.id_str) 329 | if (dmIdsReceived.indexOf(reply.id_str) !== -1) { 330 | // our response to the DM posting lost the race against the direct_message 331 | // listener (we already got the event). So we can finish the test. 332 | done() 333 | } 334 | }) 335 | }) 336 | 337 | after(function (done) { 338 | console.log('cleaning up DMs:', dmIdsSent) 339 | // delete the DMs we posted 340 | var deleteDms = dmIdsSent.map(function (dmId) { 341 | return function (next) { 342 | assert.equal(typeof dmId, 'string') 343 | console.log('\ndeleting DM', dmId) 344 | var params = { id: dmId, twit_options: { retry: true } } 345 | twitSender.post('direct_messages/destroy', params, function (err, reply) { 346 | assert(!err, err) 347 | restTest.checkDm(reply) 348 | assert.equal(reply.id, dmId) 349 | return next() 350 | }) 351 | } 352 | }) 353 | async.parallel(deleteDms, done) 354 | }) 355 | }) 356 | }) 357 | 358 | describe('streaming API friends preamble', function () { 359 | it('returns an array of strings if stringify_friend_ids is true', function (done) { 360 | var twit = new Twit(config1); 361 | var stream = twit.stream('user', { stringify_friend_ids: true }); 362 | stream.on('friends', function (friendsObj) { 363 | assert(friendsObj) 364 | assert(friendsObj.friends_str) 365 | if (friendsObj.friends_str.length) { 366 | assert.equal(typeof friendsObj.friends_str[0], 'string') 367 | } else { 368 | console.log('\nEmpty friends preamble:', friendsObj, '. Make some friends on Twitter! ^_^') 369 | } 370 | done() 371 | }) 372 | }) 373 | }) 374 | 375 | describe('streaming API bad request', function (done) { 376 | it('emits an error for a 401 response', function (done) { 377 | var badCredentials = { 378 | consumer_key: 'a' 379 | , consumer_secret: 'b' 380 | , access_token: 'c' 381 | , access_token_secret: 'd' 382 | } 383 | 384 | var twit = new Twit(badCredentials); 385 | 386 | var stream = twit.stream('statuses/filter', { track : ['foo'] }); 387 | 388 | stream.on('error', function (err) { 389 | assert.equal(err.statusCode, 401) 390 | assert(err.twitterReply) 391 | 392 | return done() 393 | }) 394 | }) 395 | }) 396 | 397 | describe('streaming API `messages` event', function (done) { 398 | var request = require('request'); 399 | var originalPost = request.post; 400 | var RewiredTwit = rewire('../lib/twitter'); 401 | var RewiredStreamingApiConnection = rewire('../lib/streaming-api-connection'); 402 | var revertParser, revertTwit; 403 | 404 | var MockParser = function () { 405 | var self = this; 406 | EventEmitter.call(self); 407 | process.nextTick(function () { 408 | self.emit('element', {scrub_geo: 'bar'}) 409 | self.emit('element', {limit: 'buzz'}) 410 | }); 411 | } 412 | util.inherits(MockParser, EventEmitter); 413 | 414 | before(function () { 415 | revertTwit = RewiredTwit.__set__('StreamingAPIConnection', RewiredStreamingApiConnection); 416 | revertParser = RewiredStreamingApiConnection.__set__('Parser', MockParser); 417 | 418 | request.post = function () { return new helpers.FakeRequest() } 419 | }) 420 | 421 | after(function () { 422 | request.post = originalPost; 423 | revertTwit(); 424 | revertParser(); 425 | }) 426 | 427 | it('is returned for 2 different event types', function (done) { 428 | var twit = new RewiredTwit(config1); 429 | var stream = twit.stream('statuses/sample'); 430 | var gotScrubGeo = false; 431 | var gotLimit = false; 432 | var numMessages = 0; 433 | 434 | var maybeDone = function () { 435 | if (gotScrubGeo && gotLimit && numMessages == 2) { 436 | done() 437 | } 438 | } 439 | 440 | stream.on('limit', function () { 441 | gotLimit = true; 442 | maybeDone(); 443 | }); 444 | stream.on('scrub_geo', function () { 445 | gotScrubGeo = true; 446 | maybeDone(); 447 | }) 448 | 449 | stream.on('message', function (msg) { 450 | numMessages++; 451 | maybeDone(); 452 | }) 453 | }) 454 | }) 455 | 456 | describe('streaming reconnect', function (done) { 457 | it('correctly implements connection closing backoff', function (done) { 458 | var stubPost = function () { 459 | var fakeRequest = new helpers.FakeRequest() 460 | process.nextTick(function () { 461 | fakeRequest.emit('close') 462 | }) 463 | return fakeRequest 464 | } 465 | 466 | var request = require('request') 467 | var stubPost = sinon.stub(request, 'post', stubPost) 468 | 469 | var twit = new Twit(config1); 470 | var stream = twit.stream('statuses/filter', { track: [ 'fun', 'yolo']}); 471 | 472 | var reconnects = [0, 250, 500, 750] 473 | var reconnectCount = -1 474 | 475 | var testDone = false 476 | 477 | stream.on('reconnect', function () { 478 | if (testDone) { 479 | return 480 | } 481 | reconnectCount += 1 482 | var expectedInterval = reconnects[reconnectCount] 483 | 484 | // make sure our connect interval is correct 485 | assert.equal(stream._connectInterval, expectedInterval); 486 | 487 | // simulate immediate reconnect by forcing a new connection (`self._connectInterval` parameter unchanged) 488 | stream._startPersistentConnection(); 489 | 490 | if (reconnectCount === reconnects.length -1) { 491 | // restore request.post 492 | stubPost.restore() 493 | testDone = true 494 | return done(); 495 | } 496 | }); 497 | }); 498 | 499 | it('correctly implements 420 backoff', function (done) { 500 | var stubPost = function () { 501 | var fakeRequest = new helpers.FakeRequest() 502 | process.nextTick(function () { 503 | var fakeResponse = new helpers.FakeResponse(420) 504 | fakeRequest.emit('response', fakeResponse) 505 | fakeRequest.emit('close') 506 | }) 507 | return fakeRequest 508 | } 509 | 510 | var request = require('request') 511 | var stubPost = sinon.stub(request, 'post', stubPost) 512 | 513 | var twit = new Twit(config1); 514 | var stream = twit.stream('statuses/filter', { track: [ 'fun', 'yolo']}); 515 | 516 | var reconnects = [60000, 120000, 240000, 480000] 517 | var reconnectCount = -1 518 | var testComplete = false 519 | 520 | stream.on('reconnect', function (req, res, connectInterval) { 521 | if (testComplete) { 522 | // prevent race between last connection attempt firing a reconnect and us validating the final 523 | // reconnect value in `reconnects` 524 | return 525 | } 526 | 527 | reconnectCount += 1 528 | var expectedInterval = reconnects[reconnectCount] 529 | 530 | // make sure our connect interval is correct 531 | assert.equal(stream._connectInterval, connectInterval); 532 | assert.equal(stream._connectInterval, expectedInterval); 533 | // simulate immediate reconnect by forcing a new connection (`self._connectInterval` parameter unchanged) 534 | stream._startPersistentConnection(); 535 | 536 | if (reconnectCount === reconnects.length -1) { 537 | // restore request.post 538 | stubPost.restore() 539 | testComplete = true 540 | return done(); 541 | } 542 | }); 543 | }); 544 | }); 545 | 546 | describe('Streaming API disconnect message', function (done) { 547 | it.skip('results in stopping the stream', function (done) { 548 | var stubPost = function () { 549 | var fakeRequest = new helpers.FakeRequest() 550 | process.nextTick(function () { 551 | var body = zlib.gzipSync(JSON.stringify({disconnect: true}) + '\r\n') 552 | var fakeResponse = new helpers.FakeResponse(200, body) 553 | fakeRequest.emit('response', fakeResponse); 554 | fakeResponse.emit('close') 555 | }); 556 | return fakeRequest 557 | } 558 | 559 | var request = require('request') 560 | var origRequest = request.post 561 | var stubs = sinon.collection 562 | stubs.stub(request, 'post', stubPost) 563 | 564 | var twit = new Twit(config1); 565 | var stream = twit.stream('statuses/filter', { track: ['fun']}); 566 | 567 | stream.on('disconnect', function (disconnMsg) { 568 | stream.stop(); 569 | // restore stub 570 | request.post = origRequest 571 | done(); 572 | }) 573 | }) 574 | }); 575 | 576 | describe.skip('Streaming API Connection limit exceeded message', function (done) { 577 | it('results in an `error` event containing the message', function (done) { 578 | var errMsg = 'Exceeded connection limit for user'; 579 | 580 | var stubPost = function () { 581 | var fakeRequest = new helpers.FakeRequest(); 582 | process.nextTick(function () { 583 | var body = zlib.gzipSync(errMsg + '\r\n'); 584 | var fakeResponse = new helpers.FakeResponse(200, body); 585 | fakeRequest.emit('response', fakeResponse); 586 | fakeResponse.emit('close'); 587 | }); 588 | return fakeRequest 589 | } 590 | 591 | var request = require('request'); 592 | var origRequest = request.post; 593 | var stubs = sinon.collection; 594 | stubs.stub(request, 'post', stubPost); 595 | 596 | var twit = new Twit(config1); 597 | var stream = twit.stream('statuses/filter'); 598 | 599 | stream.on('error', function (err) { 600 | assert(err.toString().indexOf(errMsg) !== -1, 'Unexpected error msg:' + errMsg + '.');; 601 | stream.stop(); 602 | // restore stub 603 | request.post = origRequest; 604 | done(); 605 | }) 606 | }) 607 | }) 608 | 609 | describe('Streaming API connection management', function () { 610 | it('.stop() works in all states', function (done) { 611 | var stubPost = function () { 612 | var fakeRequest = new helpers.FakeRequest(); 613 | process.nextTick(function () { 614 | var body = zlib.gzipSync('Foobar\r\n'); 615 | var fakeResponse = new helpers.FakeResponse(200, body); 616 | fakeRequest.emit('response', fakeResponse); 617 | }); 618 | return fakeRequest 619 | } 620 | 621 | var request = require('request'); 622 | var origRequest = request.post; 623 | var stubs = sinon.collection; 624 | stubs.stub(request, 'post', stubPost); 625 | 626 | var twit = new Twit(config1); 627 | 628 | var stream = twit.stream('statuses/sample'); 629 | stream.stop(); 630 | console.log('\nStopped. Restarting..'); 631 | stream.start(); 632 | stream.once('connect', function(request) { 633 | console.log('Stream emitted `connect`. Stopping & starting stream..') 634 | stream.stop(); 635 | 636 | stream.once('connected', function () { 637 | console.log('Stream emitted `connected`. Stopping stream.'); 638 | stream.stop(); 639 | 640 | stubs.restore(); 641 | done(); 642 | }); 643 | stream.start(); 644 | }); 645 | }) 646 | }) 647 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twit 2 | 3 | Twitter API Client for node 4 | 5 | Supports both the **REST** and **Streaming** API. 6 | 7 | # Installing 8 | 9 | ```shell 10 | npm install twit 11 | ``` 12 | 13 | ## Usage: 14 | 15 | ```javascript 16 | var Twit = require('twit') 17 | 18 | var T = new Twit({ 19 | consumer_key: '...', 20 | consumer_secret: '...', 21 | access_token: '...', 22 | access_token_secret: '...', 23 | timeout_ms: 60*1000, // optional HTTP request timeout to apply to all requests. 24 | strictSSL: true, // optional - requires SSL certificates to be valid. 25 | }) 26 | 27 | // 28 | // tweet 'hello world!' 29 | // 30 | T.post('statuses/update', { status: 'hello world!' }, function(err, data, response) { 31 | console.log(data) 32 | }) 33 | 34 | // 35 | // search twitter for all tweets containing the word 'banana' since July 11, 2011 36 | // 37 | T.get('search/tweets', { q: 'banana since:2011-07-11', count: 100 }, function(err, data, response) { 38 | console.log(data) 39 | }) 40 | 41 | // 42 | // get the list of user id's that follow @tolga_tezel 43 | // 44 | T.get('followers/ids', { screen_name: 'tolga_tezel' }, function (err, data, response) { 45 | console.log(data) 46 | }) 47 | 48 | // 49 | // Twit has promise support; you can use the callback API, 50 | // promise API, or both at the same time. 51 | // 52 | T.get('account/verify_credentials', { skip_status: true }) 53 | .catch(function (err) { 54 | console.log('caught error', err.stack) 55 | }) 56 | .then(function (result) { 57 | // `result` is an Object with keys "data" and "resp". 58 | // `data` and `resp` are the same objects as the ones passed 59 | // to the callback. 60 | // See https://github.com/ttezel/twit#tgetpath-params-callback 61 | // for details. 62 | 63 | console.log('data', result.data); 64 | }) 65 | 66 | // 67 | // retweet a tweet with id '343360866131001345' 68 | // 69 | T.post('statuses/retweet/:id', { id: '343360866131001345' }, function (err, data, response) { 70 | console.log(data) 71 | }) 72 | 73 | // 74 | // destroy a tweet with id '343360866131001345' 75 | // 76 | T.post('statuses/destroy/:id', { id: '343360866131001345' }, function (err, data, response) { 77 | console.log(data) 78 | }) 79 | 80 | // 81 | // get `funny` twitter users 82 | // 83 | T.get('users/suggestions/:slug', { slug: 'funny' }, function (err, data, response) { 84 | console.log(data) 85 | }) 86 | 87 | // 88 | // post a tweet with media 89 | // 90 | var b64content = fs.readFileSync('/path/to/img', { encoding: 'base64' }) 91 | 92 | // first we must post the media to Twitter 93 | T.post('media/upload', { media_data: b64content }, function (err, data, response) { 94 | // now we can assign alt text to the media, for use by screen readers and 95 | // other text-based presentations and interpreters 96 | var mediaIdStr = data.media_id_string 97 | var altText = "Small flowers in a planter on a sunny balcony, blossoming." 98 | var meta_params = { media_id: mediaIdStr, alt_text: { text: altText } } 99 | 100 | T.post('media/metadata/create', meta_params, function (err, data, response) { 101 | if (!err) { 102 | // now we can reference the media and post a tweet (media will attach to the tweet) 103 | var params = { status: 'loving life #nofilter', media_ids: [mediaIdStr] } 104 | 105 | T.post('statuses/update', params, function (err, data, response) { 106 | console.log(data) 107 | }) 108 | } 109 | }) 110 | }) 111 | 112 | // 113 | // post media via the chunked media upload API. 114 | // You can then use POST statuses/update to post a tweet with the media attached as in the example above using `media_id_string`. 115 | // Note: You can also do this yourself manually using T.post() calls if you want more fine-grained 116 | // control over the streaming. Example: https://github.com/ttezel/twit/blob/master/tests/rest_chunked_upload.js#L20 117 | // 118 | var filePath = '/absolute/path/to/file.mp4' 119 | T.postMediaChunked({ file_path: filePath }, function (err, data, response) { 120 | console.log(data) 121 | }) 122 | 123 | // 124 | // stream a sample of public statuses 125 | // 126 | var stream = T.stream('statuses/sample') 127 | 128 | stream.on('tweet', function (tweet) { 129 | console.log(tweet) 130 | }) 131 | 132 | // 133 | // filter the twitter public stream by the word 'mango'. 134 | // 135 | var stream = T.stream('statuses/filter', { track: 'mango' }) 136 | 137 | stream.on('tweet', function (tweet) { 138 | console.log(tweet) 139 | }) 140 | 141 | // 142 | // filter the public stream by the latitude/longitude bounded box of San Francisco 143 | // 144 | var sanFrancisco = [ '-122.75', '36.8', '-121.75', '37.8' ] 145 | 146 | var stream = T.stream('statuses/filter', { locations: sanFrancisco }) 147 | 148 | stream.on('tweet', function (tweet) { 149 | console.log(tweet) 150 | }) 151 | 152 | // 153 | // filter the public stream by english tweets containing `#apple` 154 | // 155 | var stream = T.stream('statuses/filter', { track: '#apple', language: 'en' }) 156 | 157 | stream.on('tweet', function (tweet) { 158 | console.log(tweet) 159 | }) 160 | 161 | ``` 162 | 163 | # twit API: 164 | 165 | ## `var T = new Twit(config)` 166 | 167 | Create a `Twit` instance that can be used to make requests to Twitter's APIs. 168 | 169 | If authenticating with user context, `config` should be an object of the form: 170 | ``` 171 | { 172 | consumer_key: '...' 173 | , consumer_secret: '...' 174 | , access_token: '...' 175 | , access_token_secret: '...' 176 | } 177 | ``` 178 | 179 | If authenticating with application context, `config` should be an object of the form: 180 | ``` 181 | { 182 | consumer_key: '...' 183 | , consumer_secret: '...' 184 | , app_only_auth: true 185 | } 186 | ``` 187 | Note that Application-only auth will not allow you to perform requests to API endpoints requiring 188 | a user context, such as posting tweets. However, the endpoints available can have a higher rate limit. 189 | 190 | ## `T.get(path, [params], callback)` 191 | GET any of the REST API endpoints. 192 | 193 | **path** 194 | 195 | The endpoint to hit. When specifying `path` values, omit the **'.json'** at the end (i.e. use **'search/tweets'** instead of **'search/tweets.json'**). 196 | 197 | **params** 198 | 199 | (Optional) parameters for the request. 200 | 201 | **callback** 202 | 203 | `function (err, data, response)` 204 | 205 | - `data` is the parsed data received from Twitter. 206 | - `response` is the [http.IncomingMessage](http://nodejs.org/api/http.html# http_http_incomingmessage) received from Twitter. 207 | 208 | ## `T.post(path, [params], callback)` 209 | 210 | POST any of the REST API endpoints. Same usage as `T.get()`. 211 | 212 | ## `T.postMediaChunked(params, callback)` 213 | 214 | Helper function to post media via the POST media/upload (chunked) API. `params` is an object containing a `file_path` key. `file_path` is the absolute path to the file you want to upload. 215 | 216 | ```js 217 | var filePath = '/absolute/path/to/file.mp4' 218 | T.postMediaChunked({ file_path: filePath }, function (err, data, response) { 219 | console.log(data) 220 | }) 221 | ``` 222 | 223 | You can also use the POST media/upload API via T.post() calls if you want more fine-grained control over the streaming; [see here for an example](https://github.com/ttezel/twit/blob/master/tests/rest_chunked_upload.js# L20). 224 | 225 | ## `T.getAuth()` 226 | Get the client's authentication tokens. 227 | 228 | ## `T.setAuth(tokens)` 229 | Update the client's authentication tokens. 230 | 231 | ## `T.stream(path, [params])` 232 | Use this with the Streaming API. 233 | 234 | **path** 235 | 236 | Streaming endpoint to hit. One of: 237 | 238 | - **'statuses/filter'** 239 | - **'statuses/sample'** 240 | - **'statuses/firehose'** 241 | - **'user'** 242 | - **'site'** 243 | 244 | For a description of each Streaming endpoint, see the [Twitter API docs](https://dev.twitter.com/streaming/overview). 245 | 246 | **params** 247 | 248 | (Optional) parameters for the request. Any Arrays passed in `params` get converted to comma-separated strings, allowing you to do requests like: 249 | 250 | ```javascript 251 | // 252 | // I only want to see tweets about my favorite fruits 253 | // 254 | 255 | // same result as doing { track: 'bananas,oranges,strawberries' } 256 | var stream = T.stream('statuses/filter', { track: ['bananas', 'oranges', 'strawberries'] }) 257 | 258 | stream.on('tweet', function (tweet) { 259 | //... 260 | }) 261 | ``` 262 | 263 | # Using the Streaming API 264 | 265 | `T.stream(path, [params])` keeps the connection alive, and returns an `EventEmitter`. 266 | 267 | The following events are emitted: 268 | 269 | ## event: 'message' 270 | 271 | Emitted each time an object is received in the stream. This is a catch-all event that can be used to process any data received in the stream, rather than using the more specific events documented below. 272 | New in version 2.1.0. 273 | 274 | ```javascript 275 | stream.on('message', function (msg) { 276 | //... 277 | }) 278 | ``` 279 | 280 | ## event: 'tweet' 281 | 282 | Emitted each time a status (tweet) comes into the stream. 283 | 284 | ```javascript 285 | stream.on('tweet', function (tweet) { 286 | //... 287 | }) 288 | ``` 289 | 290 | ## event: 'delete' 291 | 292 | Emitted each time a status (tweet) deletion message comes into the stream. 293 | 294 | ```javascript 295 | stream.on('delete', function (deleteMessage) { 296 | //... 297 | }) 298 | ``` 299 | 300 | ## event: 'limit' 301 | 302 | Emitted each time a limitation message comes into the stream. 303 | 304 | ```javascript 305 | stream.on('limit', function (limitMessage) { 306 | //... 307 | }) 308 | ``` 309 | 310 | ## event: 'scrub_geo' 311 | 312 | Emitted each time a location deletion message comes into the stream. 313 | 314 | ```javascript 315 | stream.on('scrub_geo', function (scrubGeoMessage) { 316 | //... 317 | }) 318 | ``` 319 | 320 | ## event: 'disconnect' 321 | 322 | Emitted when a disconnect message comes from Twitter. This occurs if you have multiple streams connected to Twitter's API. Upon receiving a disconnect message from Twitter, `Twit` will close the connection and emit this event with the message details received from twitter. 323 | 324 | ```javascript 325 | stream.on('disconnect', function (disconnectMessage) { 326 | //... 327 | }) 328 | ``` 329 | 330 | ## event: 'connect' 331 | 332 | Emitted when a connection attempt is made to Twitter. The http `request` object is emitted. 333 | 334 | ```javascript 335 | stream.on('connect', function (request) { 336 | //... 337 | }) 338 | ``` 339 | 340 | ## event: 'connected' 341 | 342 | Emitted when the response is received from Twitter. The http `response` object is emitted. 343 | 344 | ```javascript 345 | stream.on('connected', function (response) { 346 | //... 347 | }) 348 | ``` 349 | 350 | ## event: 'reconnect' 351 | 352 | Emitted when a reconnection attempt to Twitter is scheduled. If Twitter is having problems or we get rate limited, we schedule a reconnect according to Twitter's [reconnection guidelines](https://dev.twitter.com/streaming/overview/connecting). The last http `request` and `response` objects are emitted, along with the time (in milliseconds) left before the reconnect occurs. 353 | 354 | ```javascript 355 | stream.on('reconnect', function (request, response, connectInterval) { 356 | //... 357 | }) 358 | ``` 359 | 360 | ## event: 'warning' 361 | 362 | This message is appropriate for clients using high-bandwidth connections, like the firehose. If your connection is falling behind, Twitter will queue messages for you, until your queue fills up, at which point they will disconnect you. 363 | 364 | ```javascript 365 | stream.on('warning', function (warning) { 366 | //... 367 | }) 368 | ``` 369 | 370 | ## event: 'status_withheld' 371 | 372 | Emitted when Twitter sends back a `status_withheld` message in the stream. This means that a tweet was withheld in certain countries. 373 | 374 | ```javascript 375 | stream.on('status_withheld', function (withheldMsg) { 376 | //... 377 | }) 378 | ``` 379 | 380 | ## event: 'user_withheld' 381 | 382 | Emitted when Twitter sends back a `user_withheld` message in the stream. This means that a Twitter user was withheld in certain countries. 383 | 384 | ```javascript 385 | stream.on('user_withheld', function (withheldMsg) { 386 | //... 387 | }) 388 | ``` 389 | 390 | ## event: 'friends' 391 | 392 | Emitted when Twitter sends the ["friends" preamble](https://dev.twitter.com/streaming/overview/messages-types# user_stream_messsages) when connecting to a user stream. This message contains a list of the user's friends, represented as an array of user ids. If the [stringify_friend_ids](https://dev.twitter.com/streaming/overview/request-parameters#stringify_friend_id) parameter is set, the friends 393 | list preamble will be returned as Strings (instead of Numbers). 394 | 395 | ```javascript 396 | var stream = T.stream('user', { stringify_friend_ids: true }) 397 | stream.on('friends', function (friendsMsg) { 398 | //... 399 | }) 400 | ``` 401 | 402 | ## event: 'direct_message' 403 | 404 | Emitted when a direct message is sent to the user. Unfortunately, Twitter has not documented this event for user streams. 405 | 406 | ```javascript 407 | stream.on('direct_message', function (directMsg) { 408 | //... 409 | }) 410 | ``` 411 | 412 | ## event: 'user_event' 413 | 414 | Emitted when Twitter sends back a [User stream event](https://dev.twitter.com/streaming/overview/messages-types#Events_event). 415 | See the Twitter docs for more information on each event's structure. 416 | 417 | ```javascript 418 | stream.on('user_event', function (eventMsg) { 419 | //... 420 | }) 421 | ``` 422 | 423 | In addition, the following user stream events are provided for you to listen on: 424 | 425 | * `blocked` 426 | * `unblocked` 427 | * `favorite` 428 | * `unfavorite` 429 | * `follow` 430 | * `unfollow` 431 | * `mute` 432 | * `unmute` 433 | * `user_update` 434 | * `list_created` 435 | * `list_destroyed` 436 | * `list_updated` 437 | * `list_member_added` 438 | * `list_member_removed` 439 | * `list_user_subscribed` 440 | * `list_user_unsubscribed` 441 | * `quoted_tweet` 442 | * `retweeted_retweet` 443 | * `favorited_retweet` 444 | * `unknown_user_event` (for an event that doesn't match any of the above) 445 | 446 | ### Example: 447 | 448 | ```javascript 449 | stream.on('favorite', function (event) { 450 | //... 451 | }) 452 | ``` 453 | 454 | ## event: 'error' 455 | 456 | Emitted when an API request or response error occurs. 457 | An `Error` object is emitted, with properties: 458 | 459 | ```js 460 | { 461 | message: '...', // error message 462 | statusCode: '...', // statusCode from Twitter 463 | code: '...', // error code from Twitter 464 | twitterReply: '...', // raw response data from Twitter 465 | allErrors: '...' // array of errors returned from Twitter 466 | } 467 | ``` 468 | 469 | ## stream.stop() 470 | 471 | Call this function on the stream to stop streaming (closes the connection with Twitter). 472 | 473 | ## stream.start() 474 | 475 | Call this function to restart the stream after you called `.stop()` on it. 476 | Note: there is no need to call `.start()` to begin streaming. `Twit.stream` calls `.start()` for you. 477 | 478 | ------- 479 | 480 | # What do I have access to? 481 | 482 | Anything in the Twitter API: 483 | 484 | * REST API Endpoints: https://dev.twitter.com/rest/public 485 | * Public stream endpoints: https://dev.twitter.com/streaming/public 486 | * User stream endpoints: https://dev.twitter.com/streaming/userstreams 487 | * Site stream endpoints: https://dev.twitter.com/streaming/sitestreams 488 | 489 | ------- 490 | 491 | Go here to create an app and get OAuth credentials (if you haven't already): https://apps.twitter.com/app/new 492 | 493 | # Advanced 494 | 495 | You may specify an array of trusted certificate fingerprints if you want to only trust a specific set of certificates. 496 | When an HTTP response is received, it is verified that the certificate was signed, and the peer certificate's fingerprint must be one of the values you specified. By default, the node.js trusted "root" CAs will be used. 497 | 498 | eg. 499 | ```js 500 | var twit = new Twit({ 501 | consumer_key: '...', 502 | consumer_secret: '...', 503 | access_token: '...', 504 | access_token_secret: '...', 505 | trusted_cert_fingerprints: [ 506 | '66:EA:47:62:D9:B1:4F:1A:AE:89:5F:68:BA:6B:8E:BB:F8:1D:BF:8E', 507 | ] 508 | }) 509 | ``` 510 | 511 | # Contributing 512 | 513 | - Make your changes 514 | - Make sure your code matches the style of the code around it 515 | - Add tests that cover your feature/bugfix 516 | - Run tests 517 | - Submit a pull request 518 | 519 | # How do I run the tests? 520 | 521 | Create two files: `config1.js` and `config2.js` at the root of the `twit` folder. They should contain two different sets of oauth credentials for twit to use (two accounts are needed for testing interactions). They should both look something like this: 522 | 523 | ``` 524 | module.exports = { 525 | consumer_key: '...' 526 | , consumer_secret: '...' 527 | , access_token: '...' 528 | , access_token_secret: '...' 529 | } 530 | ``` 531 | 532 | Then run the tests: 533 | 534 | ``` 535 | npm test 536 | ``` 537 | 538 | You can also run the example: 539 | 540 | ``` 541 | node examples/rtd2.js 542 | ``` 543 | 544 | ![iRTD2](http://dl.dropbox.com/u/32773572/RTD2_logo.png) 545 | 546 | The example is a twitter bot named [RTD2](https://twitter.com/#!/iRTD2) written using `twit`. RTD2 tweets about **github** and curates its social graph. 547 | 548 | ------- 549 | 550 | [FAQ](https://github.com/ttezel/twit/wiki/FAQ) 551 | 552 | ------- 553 | 554 | ## License 555 | 556 | (The MIT License) 557 | 558 | Copyright (c) by Tolga Tezel 559 | 560 | Permission is hereby granted, free of charge, to any person obtaining a copy 561 | of this software and associated documentation files (the "Software"), to deal 562 | in the Software without restriction, including without limitation the rights 563 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 564 | copies of the Software, and to permit persons to whom the Software is 565 | furnished to do so, subject to the following conditions: 566 | 567 | The above copyright notice and this permission notice shall be included in 568 | all copies or substantial portions of the Software. 569 | 570 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 571 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 572 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 573 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 574 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 575 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 576 | THE SOFTWARE. 577 | 578 | ## Changelog 579 | 580 | ### 2.2.11 581 | * Fix media_category used for media uploads (thanks @BooDoo) 582 | 583 | ### 2.2.10 584 | * Update maximum Tweet characters to 280 (thanks @maziyarpanahi) 585 | * For streaming requests, use request body for sending params (thanks @raine) 586 | * Fix getBearerToken endpoint (thanks @williamcoates) 587 | * Shared Parameter Feature For Media Upload (thanks @haroonabbasi) 588 | * Don't include params in path for jsonpayload paths (thanks @egtoney) 589 | * Add support for strictSSL request option (thanks @zdoc01) 590 | 591 | ### 2.2.9 592 | * Use JSON payload in request body for new DM endpoints. 593 | 594 | ### 2.2.8 595 | * Add support for HTTP DELETE; you can now `T.delete(...)`. 596 | 597 | ### 2.2.7 598 | * Don't attempt to reconnect to Twitter API when receiving HTTP status code 413 - request entity too large. 599 | 600 | ### 2.2.6 601 | * Fix zlib error when streaming 602 | 603 | ### 2.2.4 604 | * Fix 401 Unauthorized error on streaming connection reconnect after not being 605 | connected for some time (eg. due to > 1min loss of network). 606 | 607 | ### 2.2.2 608 | * Emit `parser-error` instead of `error` event if Twitter sends back 609 | an uncompressed HTTP response body. 610 | 611 | ### 2.2.1 612 | * Add promise support to Twit REST API calls. 613 | 614 | ### 2.2.0 615 | * Allow omission of `new` keyword; `var t = Twit(config)` works, and `var t = new Twit(config)` works too. 616 | * Allow setting an array of trusted certificate fingerprints via `config.trusted_cert_fingerprints`. 617 | * Automatically adjust timestamp for OAuth'ed HTTP requests 618 | by recording the timestamp from Twitter HTTP responses, computing our local time offset, and applying the offset in the next HTTP request to Twitter. 619 | 620 | ### 2.1.7 621 | * Add `mime` as a dependency. 622 | 623 | ### 2.1.6 624 | * Emit `friends` event for `friends_str` message received when a user stream is requested with `stringify_friend_ids=true`. 625 | * Handle receiving "Exceeded connection limit for user" message from Twitter while streaming. Emit `error` event for this case. 626 | * Emit `retweeted_retweet` and `favorited_retweet` user events. 627 | * Add MIT license to package.json (about time!) 628 | 629 | ### 2.1.5 630 | * Support config-based request timeout. 631 | 632 | ### 2.1.4 633 | * Support POST media/upload (chunked) and add `T.postMediaChunked()` to make it easy. 634 | 635 | ### 2.1.3 636 | * Fix bug in constructing HTTP requests for `account/update_profile_image` and `account/update_profile_background_image` paths. 637 | 638 | ### 2.1.2 639 | * Enable gzip on network traffic 640 | * Add `quoted_tweet` event 641 | 642 | ### 2.1.1 643 | * Strict-mode fixes (Twit can now be run with strict mode) 644 | * Fix handling of disconect message from Twitter 645 | * If Twitter returns a non-JSON-parseable fragment during streaming, emit 'parser-error' instead of 'error' (to discard fragments like "Internal Server Error") 646 | 647 | ### 2.1.0 648 | * Add `message` event. 649 | 650 | ### 2.0.0 651 | * Implement Application-only auth 652 | * Remove oauth module as a dependency 653 | 654 | ### 1.1.20 655 | * Implement support for POST /media/upload 656 | * Reconnect logic fix for streaming; add stall abort/reconnect timeout on first connection attempt. 657 | 658 | ### 1.1.14 659 | * Emit `connected` event upon receiving the response from twitter 660 | 661 | ### 1.0.0 662 | * now to stop and start the stream, use `stream.stop()` and `stream.start()` instead of emitting the `start` and `stop` events 663 | * If twitter sends a `disconnect` message, closes the stream and emits `disconnect` with the disconnect message received from twitter 664 | 665 | ### 0.2.0 666 | * Updated `twit` for usage with v1.1 of the Twitter API. 667 | 668 | ### 0.1.5 669 | 670 | * **BREAKING CHANGE** to `twit.stream()`. Does not take a callback anymore. It returns 671 | immediately with the `EventEmitter` that you can listen on. The `Usage` section in 672 | the Readme.md has been updated. Read it. 673 | 674 | 675 | ### 0.1.4 676 | 677 | * `twit.stream()` has signature `function (path, params, callback)` 678 | -------------------------------------------------------------------------------- /tests/rest.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , EventEmitter = require('events').EventEmitter 3 | , fs = require('fs') 4 | , sinon = require('sinon') 5 | , Twit = require('../lib/twitter') 6 | , config1 = require('../config1') 7 | , config2 = require('../config2') 8 | , helpers = require('./helpers') 9 | , util = require('util') 10 | , async = require('async') 11 | 12 | describe('REST API', function () { 13 | var twit = null 14 | 15 | before(function () { 16 | twit = new Twit(config1); 17 | }) 18 | 19 | it('GET `account/verify_credentials`', function (done) { 20 | twit.get('account/verify_credentials', function (err, reply, response) { 21 | checkReply(err, reply) 22 | assert.notEqual(reply.followers_count, undefined) 23 | assert.notEqual(reply.friends_count, undefined) 24 | assert.ok(reply.id_str) 25 | 26 | checkResponse(response) 27 | 28 | assert(response.headers['x-rate-limit-limit']) 29 | done() 30 | }) 31 | }) 32 | 33 | it('GET `account/verify_credentials` using promise API only', function (done) { 34 | twit 35 | .get('account/verify_credentials', { skip_status: true }) 36 | .catch(function (err) { 37 | console.log('catch err', err.stack) 38 | }) 39 | .then(function (result) { 40 | checkReply(null, result.data) 41 | assert.notEqual(result.data.followers_count, undefined) 42 | assert.notEqual(result.data.friends_count, undefined) 43 | assert.ok(result.data.id_str) 44 | checkResponse(result.resp) 45 | assert(result.resp.headers['x-rate-limit-limit']) 46 | 47 | done() 48 | }) 49 | }) 50 | 51 | it.skip('GET `account/verify_credentials` using promise API AND callback API', function (done) { 52 | var i = 0; 53 | 54 | var _checkDataAndResp = function (data, resp) { 55 | checkReply(null, data) 56 | assert.notEqual(data.followers_count, undefined) 57 | assert.notEqual(data.friends_count, undefined) 58 | assert.ok(data.id_str) 59 | checkResponse(resp) 60 | assert(resp.headers['x-rate-limit-limit']) 61 | 62 | i++; 63 | if (i == 2) { 64 | done() 65 | } 66 | } 67 | 68 | var get = twit.get('account/verify_credentials', function (err, data, resp) { 69 | assert(!err, err); 70 | _checkDataAndResp(data, resp); 71 | }); 72 | get.catch(function (err) { 73 | console.log('Got error:', err.stack) 74 | }); 75 | get.then(function (result) { 76 | _checkDataAndResp(result.data, result.resp); 77 | }); 78 | }) 79 | 80 | it('POST `account/update_profile`', function (done) { 81 | twit.post('account/update_profile', function (err, reply, response) { 82 | checkReply(err, reply) 83 | console.log('\nscreen name:', reply.screen_name) 84 | checkResponse(response) 85 | done() 86 | }) 87 | }) 88 | 89 | it('POST `statuses/update` and POST `statuses/destroy:id`', function (done) { 90 | var tweetId = null 91 | 92 | var params = { 93 | status: '@tolga_tezel tweeting using github.com/ttezel/twit. ' + helpers.generateRandomString(7) 94 | } 95 | twit.post('statuses/update', params, function (err, reply, response) { 96 | checkReply(err, reply) 97 | console.log('\ntweeted:', reply.text) 98 | 99 | tweetId = reply.id_str 100 | assert(tweetId) 101 | checkResponse(response) 102 | 103 | var deleteParams = { id: tweetId } 104 | // Try up to 2 times to delete the tweet. 105 | // Even after a 200 response to statuses/update is returned, a delete might 404 so we retry. 106 | exports.req_with_retries(twit, 2, 'post', 'statuses/destroy/:id', deleteParams, [404], function (err, body, response) { 107 | checkReply(err, body) 108 | checkTweet(body) 109 | checkResponse(response) 110 | 111 | done() 112 | }) 113 | }) 114 | }) 115 | 116 | it('POST `statuses/update` with characters requiring escaping', function (done) { 117 | var params = { status: '@tolga_tezel tweeting using github.com/ttezel/twit :) !' + helpers.generateRandomString(15) } 118 | 119 | twit.post('statuses/update', params, function (err, reply, response) { 120 | checkReply(err, reply) 121 | 122 | console.log('\ntweeted:', reply.text) 123 | 124 | checkResponse(response) 125 | 126 | var text = reply.text 127 | 128 | assert(reply.id_str) 129 | 130 | twit.post('statuses/destroy/:id', { id: reply.id_str }, function (err, reply, response) { 131 | checkReply(err, reply) 132 | checkTweet(reply) 133 | assert.equal(reply.text, text) 134 | 135 | checkResponse(response) 136 | 137 | done() 138 | }) 139 | }) 140 | }) 141 | 142 | it('POST `statuses/update` with \'Hi!!\' works', function (done) { 143 | var params = { status: 'Hi!!' } 144 | 145 | twit.post('statuses/update', params, function (err, reply, response) { 146 | checkReply(err, reply) 147 | 148 | console.log('\ntweeted:', reply.text) 149 | 150 | checkResponse(response) 151 | 152 | var text = reply.text 153 | 154 | var destroyRoute = 'statuses/destroy/'+reply.id_str 155 | 156 | twit.post(destroyRoute, function (err, reply, response) { 157 | checkReply(err, reply) 158 | checkTweet(reply) 159 | assert.equal(reply.text, text) 160 | 161 | checkResponse(response) 162 | 163 | done() 164 | }) 165 | }) 166 | }) 167 | 168 | it('GET `statuses/home_timeline`', function (done) { 169 | twit.get('statuses/home_timeline', function (err, reply, response) { 170 | checkReply(err, reply) 171 | checkTweet(reply[0]) 172 | 173 | checkResponse(response) 174 | 175 | done() 176 | }) 177 | }) 178 | 179 | it('GET `statuses/mentions_timeline`', function (done) { 180 | twit.get('statuses/mentions_timeline', function (err, reply, response) { 181 | checkReply(err, reply) 182 | checkTweet(reply[0]) 183 | done() 184 | }) 185 | }) 186 | 187 | it('GET `statuses/user_timeline`', function (done) { 188 | var params = { 189 | screen_name: 'tolga_tezel' 190 | } 191 | 192 | twit.get('statuses/user_timeline', params, function (err, reply, response) { 193 | checkReply(err, reply) 194 | checkTweet(reply[0]) 195 | 196 | checkResponse(response) 197 | 198 | done() 199 | }) 200 | }) 201 | 202 | it('GET `search/tweets` { q: "a", since_id: 12345 }', function (done) { 203 | var params = { q: 'a', since_id: 12345 } 204 | twit.get('search/tweets', params, function (err, reply, response) { 205 | checkReply(err, reply) 206 | assert.ok(reply.statuses) 207 | checkTweet(reply.statuses[0]) 208 | 209 | checkResponse(response) 210 | 211 | done() 212 | }) 213 | }) 214 | 215 | it('GET `search/tweets` { q: "fun since:2011-11-11" }', function (done) { 216 | var params = { q: 'fun since:2011-11-11', count: 100 } 217 | twit.get('search/tweets', params, function (err, reply, response) { 218 | checkReply(err, reply) 219 | assert.ok(reply.statuses) 220 | checkTweet(reply.statuses[0]) 221 | 222 | console.log('\nnumber of fun statuses:', reply.statuses.length) 223 | 224 | checkResponse(response) 225 | 226 | done() 227 | }) 228 | }) 229 | 230 | it('GET `search/tweets`, using `q` array', function (done) { 231 | var params = { 232 | q: [ 'banana', 'mango', 'peach' ] 233 | } 234 | 235 | twit.get('search/tweets', params, function (err, reply, response) { 236 | checkReply(err, reply) 237 | assert.ok(reply.statuses) 238 | checkTweet(reply.statuses[0]) 239 | 240 | checkResponse(response) 241 | 242 | done() 243 | }) 244 | }) 245 | 246 | it('GET `search/tweets` with count set to 100', function (done) { 247 | var params = { 248 | q: 'happy', 249 | count: 100 250 | } 251 | 252 | twit.get('search/tweets', params, function (err, reply, res) { 253 | checkReply(err, reply) 254 | console.log('\nnumber of tweets from search:', reply.statuses.length) 255 | // twitter won't always send back 100 tweets if we ask for 100, 256 | // but make sure it's close to 100 257 | assert(reply.statuses.length > 95) 258 | 259 | done() 260 | }) 261 | }) 262 | 263 | it('GET `search/tweets` with geocode', function (done) { 264 | var params = { 265 | q: 'apple', geocode: [ '37.781157', '-122.398720', '1mi' ] 266 | } 267 | 268 | twit.get('search/tweets', params, function (err, reply) { 269 | checkReply(err, reply) 270 | 271 | done() 272 | }) 273 | }) 274 | 275 | it('GET `direct_messages`', function (done) { 276 | twit.get('direct_messages', function (err, reply, response) { 277 | checkResponse(response) 278 | checkReply(err, reply) 279 | assert.ok(Array.isArray(reply)) 280 | done() 281 | }) 282 | }) 283 | 284 | it('GET `followers/ids`', function (done) { 285 | twit.get('followers/ids', function (err, reply, response) { 286 | checkReply(err, reply) 287 | assert.ok(Array.isArray(reply.ids)) 288 | 289 | checkResponse(response) 290 | 291 | done() 292 | }) 293 | }) 294 | 295 | it('GET `followers/ids` of screen_name tolga_tezel', function (done) { 296 | twit.get('followers/ids', { screen_name: 'tolga_tezel' }, function (err, reply, response) { 297 | checkReply(err, reply) 298 | assert.ok(Array.isArray(reply.ids)) 299 | 300 | checkResponse(response) 301 | 302 | done() 303 | }) 304 | }) 305 | 306 | it('POST `statuses/retweet`', function (done) { 307 | // search for a tweet to retweet 308 | twit.get('search/tweets', { q: 'apple' }, function (err, reply, response) { 309 | checkReply(err, reply) 310 | assert.ok(reply.statuses) 311 | 312 | var tweet = reply.statuses[0] 313 | checkTweet(tweet) 314 | 315 | var tweetId = tweet.id_str 316 | assert(tweetId) 317 | 318 | twit.post('statuses/retweet/'+tweetId, function (err, reply, response) { 319 | checkReply(err, reply) 320 | 321 | var retweetId = reply.id_str 322 | assert(retweetId) 323 | 324 | twit.post('statuses/destroy/'+retweetId, function (err, reply, response) { 325 | checkReply(err, reply) 326 | 327 | done() 328 | }) 329 | }) 330 | }) 331 | }) 332 | 333 | // 1.1.8 usage 334 | it('POST `statuses/retweet/:id` without `id` in params returns error', function (done) { 335 | twit.post('statuses/retweet/:id', function (err, reply, response) { 336 | assert(err) 337 | assert.equal(err.message, 'Twit: Params object is missing a required parameter for this request: `id`') 338 | done() 339 | }) 340 | }) 341 | 342 | // 1.1.8 usage 343 | it('POST `statuses/retweet/:id`', function (done) { 344 | // search for a tweet to retweet 345 | twit.get('search/tweets', { q: 'banana' }, function (err, reply, response) { 346 | checkReply(err, reply) 347 | assert.ok(reply.statuses) 348 | 349 | var tweet = reply.statuses[0] 350 | checkTweet(tweet) 351 | 352 | var tweetId = tweet.id_str 353 | assert(tweetId) 354 | 355 | twit.post('statuses/retweet/:id', { id: tweetId }, function (err, reply) { 356 | checkReply(err, reply) 357 | 358 | var retweetId = reply.id_str 359 | assert(retweetId) 360 | 361 | twit.post('statuses/destroy/:id', { id: retweetId }, function (err, reply, response) { 362 | checkReply(err, reply) 363 | 364 | done() 365 | }) 366 | }) 367 | }) 368 | }) 369 | 370 | // 1.1.8 usage 371 | // skip for now since this API call is having problems on Twitter's side (404) 372 | it.skip('GET `users/suggestions/:slug`', function (done) { 373 | twit.get('users/suggestions/:slug', { slug: 'funny' }, function (err, reply, res) { 374 | checkReply(err, reply) 375 | assert.equal(reply.slug, 'funny') 376 | done() 377 | }) 378 | }) 379 | 380 | // 1.1.8 usage 381 | // skip for now since this API call is having problems on Twitter's side (404) 382 | it.skip('GET `users/suggestions/:slug/members`', function (done) { 383 | twit.get('users/suggestions/:slug/members', { slug: 'funny' }, function (err, reply, res) { 384 | checkReply(err, reply) 385 | 386 | assert(reply[0].id_str) 387 | assert(reply[0].screen_name) 388 | 389 | done() 390 | }) 391 | }) 392 | 393 | // 1.1.8 usage 394 | it('GET `geo/id/:place_id`', function (done) { 395 | var placeId = 'df51dec6f4ee2b2c' 396 | 397 | twit.get('geo/id/:place_id', { place_id: placeId }, function (err, reply, res) { 398 | checkReply(err, reply) 399 | 400 | assert(reply.country) 401 | assert(reply.bounding_box) 402 | assert.equal(reply.id, placeId) 403 | 404 | done() 405 | }) 406 | }) 407 | 408 | it('POST `direct_messages/new`', function (done) { 409 | var dmId 410 | 411 | async.series({ 412 | postDm: function (next) { 413 | 414 | var dmParams = { 415 | screen_name: 'tolga_tezel', 416 | text: 'hey this is a direct message from twit! :) ' + helpers.generateRandomString(15) 417 | } 418 | // post a direct message from the sender's account 419 | twit.post('direct_messages/new', dmParams, function (err, reply) { 420 | assert(!err, err) 421 | assert(reply) 422 | 423 | dmId = reply.id_str 424 | 425 | exports.checkDm(reply) 426 | 427 | assert.equal(reply.text, dmParams.text) 428 | assert(dmId) 429 | 430 | return next() 431 | }) 432 | }, 433 | deleteDm: function (next) { 434 | twit.post('direct_messages/destroy', { id: dmId }, function (err, reply) { 435 | assert(!err, err) 436 | exports.checkDm(reply) 437 | assert.equal(reply.id, dmId) 438 | 439 | return next() 440 | }) 441 | } 442 | }, done); 443 | }) 444 | 445 | describe('Url construction', function () { 446 | var twit = null 447 | var parameters = { 448 | elem1: 'hello world', 449 | foo: 'bar' 450 | } 451 | 452 | before(function () { 453 | twit = new Twit(config1) 454 | }) 455 | 456 | it('adds query parameters to url', function (done) { 457 | var resp = twit._buildReqOpts('POST', 'account/verify_credentials', parameters, false, function (err, data) { 458 | assert.equal(data.url, 'https://api.twitter.com/1.1/account/verify_credentials.json?elem1=hello%20world&foo=bar') 459 | done() 460 | }) 461 | }) 462 | 463 | it('does not add query parameters to url when json should be in the payload', function (done) { 464 | var resp = twit._buildReqOpts('POST', 'direct_messages/welcome_messages/new', parameters, false, function (err, data) { 465 | assert.equal(data.url, 'https://api.twitter.com/1.1/direct_messages/welcome_messages/new.json') 466 | done() 467 | }) 468 | }) 469 | }) 470 | 471 | describe('Media Upload', function () { 472 | var twit = null 473 | 474 | before(function () { 475 | twit = new Twit(config1) 476 | }) 477 | 478 | it('POST media/upload with png', function (done) { 479 | var b64content = fs.readFileSync(__dirname + '/img/cutebird.png', { encoding: 'base64' }) 480 | 481 | twit.post('media/upload', { media_data: b64content }, function (err, data, response) { 482 | assert.equal(response.statusCode, 200) 483 | assert(!err, err) 484 | exports.checkMediaUpload(data) 485 | assert(data.image.image_type == 'image/png' || data.image.image_type == 'image\/png') 486 | done() 487 | }) 488 | }) 489 | 490 | it('POST media/upload with JPG', function (done) { 491 | var b64content = fs.readFileSync(__dirname + '/img/bigbird.jpg', { encoding: 'base64' }) 492 | 493 | twit.post('media/upload', { media_data: b64content }, function (err, data, response) { 494 | assert(!err, err) 495 | exports.checkMediaUpload(data) 496 | assert.equal(data.image.image_type, 'image/jpeg') 497 | done() 498 | }) 499 | }) 500 | 501 | it('POST media/upload with static GIF', function (done) { 502 | var b64content = fs.readFileSync(__dirname + '/img/twitterbird.gif', { encoding: 'base64' }) 503 | 504 | twit.post('media/upload', { media_data: b64content }, function (err, data, response) { 505 | assert(!err, err) 506 | exports.checkMediaUpload(data) 507 | assert.equal(data.image.image_type, 'image/gif') 508 | done() 509 | }) 510 | }) 511 | 512 | it('POST media/upload with animated GIF using `media_data` parameter', function (done) { 513 | var b64content = fs.readFileSync(__dirname + '/img/snoopy-animated.gif', { encoding: 'base64' }) 514 | 515 | twit.post('media/upload', { media_data: b64content }, function (err, data, response) { 516 | assert(!err, err) 517 | exports.checkMediaUpload(data) 518 | var expected_image_types = ['image/gif', 'image/animatedgif'] 519 | var image_type = data.image.image_type 520 | assert.ok(expected_image_types.indexOf(image_type) !== -1, 'got unexpected image type:' + image_type) 521 | done() 522 | }) 523 | }) 524 | 525 | it('POST media/upload with animated GIF, then POST a tweet referencing the media', function (done) { 526 | var b64content = fs.readFileSync(__dirname + '/img/snoopy-animated.gif', { encoding: 'base64' }); 527 | 528 | twit.post('media/upload', { media_data: b64content }, function (err, data, response) { 529 | assert(!err, err) 530 | exports.checkMediaUpload(data) 531 | var expected_image_types = ['image/gif', 'image/animatedgif'] 532 | var image_type = data.image.image_type 533 | assert.ok(expected_image_types.indexOf(image_type) !== -1, 'got unexpected image type:' + image_type) 534 | 535 | var mediaIdStr = data.media_id_string 536 | assert(mediaIdStr) 537 | var params = { status: '#nofilter', media_ids: [mediaIdStr] } 538 | twit.post('statuses/update', params, function (err, data, response) { 539 | assert(!err, err) 540 | var tweetIdStr = data.id_str 541 | assert(tweetIdStr) 542 | 543 | exports.req_with_retries(twit, 3, 'post', 'statuses/destroy/:id', { id: tweetIdStr }, [404], function (err, data, response) { 544 | checkReply(err, data) 545 | done() 546 | }) 547 | }) 548 | }) 549 | }) 550 | 551 | it('POST media/upload with animated GIF using `media` parameter', function (done) { 552 | var b64Content = fs.readFileSync(__dirname + '/img/snoopy-animated.gif', { encoding: 'base64' }); 553 | 554 | twit.post('media/upload', { media: b64Content }, function (err, data, response) { 555 | assert(!err, err) 556 | exports.checkMediaUpload(data) 557 | var expected_image_types = ['image/gif', 'image/animatedgif'] 558 | var image_type = data.image.image_type 559 | assert.ok(expected_image_types.indexOf(image_type) !== -1, 'got unexpected image type:' + image_type) 560 | done() 561 | }) 562 | }) 563 | 564 | it('POST media/upload with JPG, then POST media/metadata/create with alt text', function (done) { 565 | var b64content = fs.readFileSync(__dirname + '/img/bigbird.jpg', { encoding: 'base64' }) 566 | 567 | twit.post('media/upload', { media_data: b64content }, function (err, data, response) { 568 | assert(!err, err) 569 | exports.checkMediaUpload(data) 570 | assert.equal(data.image.image_type, 'image/jpeg') 571 | 572 | var mediaIdStr = data.media_id_string 573 | assert(mediaIdStr) 574 | var altText = 'a very small Big Bird' 575 | var params = { media_id: mediaIdStr, alt_text: { text: altText } } 576 | twit.post('media/metadata/create', params, function (err, data, response) { 577 | assert(!err, err) 578 | // data is empty on media/metadata/create success; nothing more to assert 579 | done(); 580 | }) 581 | }) 582 | }) 583 | }) 584 | 585 | it('POST account/update_profile_image', function (done) { 586 | var b64content = fs.readFileSync(__dirname + '/img/snoopy-animated.gif', { encoding: 'base64' }) 587 | 588 | twit.post('account/update_profile_image', { image: b64content }, function (err, data, response) { 589 | assert(!err, err); 590 | exports.checkReply(err, data); 591 | exports.checkUser(data); 592 | 593 | done() 594 | }) 595 | }) 596 | 597 | it('POST friendships/create', function (done) { 598 | var params = { screen_name: 'tolga_tezel', follow: false }; 599 | twit.post('friendships/create', params, function (err, data, resp) { 600 | assert(!err, err); 601 | exports.checkReply(err, data); 602 | exports.checkUser(data); 603 | done(); 604 | }); 605 | }) 606 | 607 | describe('Favorites', function () { 608 | it('POST favorites/create and POST favorites/destroy work', function (done) { 609 | twit.post('favorites/create', { id: '583531943624597504' }, function (err, data, resp) { 610 | assert(!err, err); 611 | exports.checkReply(err, data); 612 | var tweetIdStr = data.id_str; 613 | assert(tweetIdStr); 614 | 615 | twit.post('favorites/destroy', { id: tweetIdStr }, function (err, data, resp) { 616 | assert(!err, err); 617 | exports.checkReply(err, data); 618 | assert(data.id_str); 619 | assert(data.text); 620 | 621 | done(); 622 | }) 623 | }) 624 | }) 625 | }) 626 | 627 | describe('error handling', function () { 628 | describe('handling errors from the twitter api', function () { 629 | var twit = new Twit({ 630 | consumer_key: 'a', 631 | consumer_secret: 'b', 632 | access_token: 'c', 633 | access_token_secret: 'd' 634 | }) 635 | it('should callback with an Error object with all the info and a response object', function (done) { 636 | twit.get('account/verify_credentials', function (err, reply, res) { 637 | assert(err instanceof Error) 638 | assert(err.statusCode === 401) 639 | assert(err.code > 0) 640 | assert(err.message.match(/token/)) 641 | assert(err.twitterReply) 642 | assert(err.allErrors) 643 | assert(res) 644 | assert(res.headers) 645 | assert.equal(res.statusCode, 401) 646 | done() 647 | }) 648 | }) 649 | it('should return a rejected promise with the Twitter API errors', function() { 650 | twit.get('account/verify_credentials') 651 | .then(result => { 652 | throw Error("Twitter API exception was not caught in the promise API") 653 | }) 654 | .catch(err => { 655 | assert(err instanceof Error) 656 | assert(err.statusCode === 401) 657 | assert(err.code > 0) 658 | assert(err.message.match(/token/)) 659 | assert(err.twitterReply) 660 | assert(err.allErrors) 661 | return; 662 | }) 663 | }) 664 | }) 665 | describe('handling other errors', function () { 666 | it('should just forward errors raised by underlying request lib', function (done) { 667 | var twit = new Twit(config1); 668 | var fakeError = new Error('derp') 669 | 670 | var FakeRequest = function () { 671 | EventEmitter.call(this) 672 | } 673 | util.inherits(FakeRequest, EventEmitter) 674 | 675 | var stubGet = function () { 676 | var fakeRequest = new FakeRequest() 677 | process.nextTick(function () { 678 | fakeRequest.emit('error', fakeError) 679 | }) 680 | return fakeRequest 681 | } 682 | 683 | var request = require('request') 684 | var stubGet = sinon.stub(request, 'get', stubGet) 685 | 686 | twit.get('account/verify_credentials', function (err, reply, res) { 687 | assert(err === fakeError) 688 | 689 | // restore request.get 690 | stubGet.restore() 691 | 692 | done() 693 | }) 694 | }) 695 | }) 696 | 697 | describe('Request timeout', function () { 698 | it('set to 1ms should return with a timeout error', function (done) { 699 | config1.timeout_ms = 1; 700 | var twit = new Twit(config1); 701 | twit.get('account/verify_credentials', function (err, reply, res) { 702 | assert(err) 703 | assert.equal(err.message, 'ETIMEDOUT') 704 | delete config1.timeout_ms 705 | done() 706 | }) 707 | }) 708 | }) 709 | }); 710 | }); 711 | 712 | describe('Twit agent_options config', function () { 713 | it.skip('config.trusted_cert_fingerprints works against cert fingerprint for api.twitter.com:443', function (done) { 714 | // TODO: mock getPeerCertificate so we don't have to pin here 715 | config1.trusted_cert_fingerprints = [ 716 | '50:D9:10:E8:B4:CD:A9:82:E1:FA:6A:43:48:6F:3B:3F:3C:31:A0:8B' 717 | ]; 718 | var t = new Twit(config1); 719 | 720 | t.get('account/verify_credentials', function (err, data, resp) { 721 | assert(!err, err) 722 | assert(data) 723 | assert(data.id_str) 724 | assert(data.name) 725 | assert(data.screen_name) 726 | 727 | delete config1.trusted_cert_fingerprints 728 | done(); 729 | }) 730 | }) 731 | 732 | it.skip('config.trusted_cert_fingerprints responds with Error when fingerprint mismatch occurs', function (done) { 733 | config1.trusted_cert_fingerprints = [ 734 | 'AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA' 735 | ]; 736 | var t = new Twit(config1); 737 | 738 | t.get('account/verify_credentials', function (err, data, resp) { 739 | assert(err) 740 | assert(err.toString().indexOf('Trusted fingerprints are: ' + config1.trusted_cert_fingerprints[0]) !== -1) 741 | 742 | delete config1.trusted_cert_fingerprints 743 | done(); 744 | }) 745 | }) 746 | }) 747 | 748 | describe('Local time offset compensation', function () { 749 | it('Compensates for local time being behind', function (done) { 750 | var t1 = Date.now(); 751 | var t = new Twit(config2); 752 | 753 | var stubNow = function () { 754 | return 0; 755 | } 756 | var stubDateNow = sinon.stub(Date, 'now', stubNow); 757 | 758 | t.get('account/verify_credentials', function (err, data, resp) { 759 | assert(err); 760 | 761 | t.get('account/verify_credentials', function (err, data, resp) { 762 | assert(!err, err); 763 | exports.checkReply(err, data); 764 | exports.checkUser(data); 765 | assert(t._twitter_time_minus_local_time_ms > 0) 766 | 767 | stubDateNow.restore(); 768 | 769 | done(); 770 | }) 771 | }) 772 | }) 773 | }) 774 | 775 | /** 776 | * Basic validation to verify we have no error and reply is an object 777 | * 778 | * @param {error} err error object (or null) 779 | * @param {object} reply reply object received from twitter 780 | */ 781 | var checkReply = exports.checkReply = function (err, reply) { 782 | assert.equal(err, null, 'reply err:'+util.inspect(err, true, 10, true)) 783 | assert.equal(typeof reply, 'object') 784 | } 785 | 786 | /** 787 | * check the http response object and its headers 788 | * @param {object} response http response object 789 | */ 790 | var checkResponse = exports.checkResponse = function (response) { 791 | assert(response) 792 | assert(response.headers) 793 | assert.equal(response.statusCode, 200) 794 | } 795 | 796 | /** 797 | * validate that @tweet is a tweet object 798 | * 799 | * @param {object} tweet `tweet` object received from twitter 800 | */ 801 | var checkTweet = exports.checkTweet = function (tweet) { 802 | assert.ok(tweet) 803 | assert.equal('string', typeof tweet.id_str, 'id_str wasnt string:'+tweet.id_str) 804 | assert.equal('string', typeof tweet.text) 805 | 806 | assert.ok(tweet.user) 807 | assert.equal('string', typeof tweet.user.id_str) 808 | assert.equal('string', typeof tweet.user.screen_name) 809 | } 810 | 811 | /** 812 | * Validate that @dm is a direct message object 813 | * 814 | * @param {object} dm `direct message` object received from twitter 815 | */ 816 | exports.checkDm = function checkDm (dm) { 817 | assert.ok(dm) 818 | assert.equal('string', typeof dm.id_str) 819 | assert.equal('string', typeof dm.text) 820 | 821 | var recipient = dm.recipient 822 | 823 | assert.ok(recipient) 824 | assert.equal('string', typeof recipient.id_str) 825 | assert.equal('string', typeof recipient.screen_name) 826 | 827 | var sender = dm.sender 828 | 829 | assert.ok(sender) 830 | assert.equal('string', typeof sender.id_str) 831 | assert.equal('string', typeof sender.screen_name) 832 | 833 | assert.equal('string', typeof dm.text) 834 | } 835 | 836 | exports.checkMediaUpload = function checkMediaUpload (data) { 837 | assert.ok(data) 838 | assert.ok(data.image) 839 | assert.ok(data.image.w) 840 | assert.ok(data.image.h) 841 | assert.ok(data.media_id) 842 | assert.equal('string', typeof data.media_id_string) 843 | assert.ok(data.size) 844 | } 845 | 846 | exports.checkUser = function checkUser (data) { 847 | assert.ok(data) 848 | assert.ok(data.id_str) 849 | assert.ok(data.name) 850 | assert.ok(data.screen_name) 851 | } 852 | 853 | exports.assertTweetHasText = function (tweet, text) { 854 | assert(tweet.text.toLowerCase().indexOf(text) !== -1, 'expected to find '+text+' in text: '+tweet.text); 855 | } 856 | 857 | exports.req_with_retries = function (twit_instance, num_tries, verb, path, params, status_codes_to_retry, cb) { 858 | twit_instance[verb](path, params, function (err, data, response) { 859 | if (!num_tries || (status_codes_to_retry.indexOf(response.statusCode) === -1)) { 860 | return cb(err, data, response) 861 | } 862 | 863 | exports.req_with_retries(twit_instance, num_tries - 1, verb, path, params, status_codes_to_retry, cb) 864 | }) 865 | } 866 | --------------------------------------------------------------------------------