twitter.js (16533B)
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