mirror of
https://github.com/thewesker/bug-em.git
synced 2025-12-22 04:51:05 -05:00
486 lines
15 KiB
JavaScript
486 lines
15 KiB
JavaScript
//
|
|
// Twitter API Wrapper
|
|
//
|
|
var assert = require('assert');
|
|
var Promise = require('bluebird');
|
|
var request = require('request');
|
|
var util = require('util');
|
|
var endpoints = require('./endpoints');
|
|
var FileUploader = require('./file_uploader');
|
|
var helpers = require('./helpers');
|
|
var StreamingAPIConnection = require('./streaming-api-connection');
|
|
var STATUS_CODES_TO_ABORT_ON = require('./settings').STATUS_CODES_TO_ABORT_ON;
|
|
|
|
// config values required for app-only auth
|
|
var required_for_app_auth = [
|
|
'consumer_key',
|
|
'consumer_secret'
|
|
];
|
|
|
|
// config values required for user auth (superset of app-only auth)
|
|
var required_for_user_auth = required_for_app_auth.concat([
|
|
'access_token',
|
|
'access_token_secret'
|
|
]);
|
|
|
|
var FORMDATA_PATHS = [
|
|
'media/upload',
|
|
'account/update_profile_image',
|
|
'account/update_profile_background_image',
|
|
];
|
|
|
|
var JSONPAYLOAD_PATHS = [
|
|
'media/metadata/create'
|
|
]
|
|
|
|
//
|
|
// Twitter
|
|
//
|
|
var Twitter = function (config) {
|
|
if (!(this instanceof Twitter)) {
|
|
return new Twitter(config);
|
|
}
|
|
|
|
var self = this
|
|
var credentials = {
|
|
consumer_key : config.consumer_key,
|
|
consumer_secret : config.consumer_secret,
|
|
// access_token and access_token_secret only required for user auth
|
|
access_token : config.access_token,
|
|
access_token_secret : config.access_token_secret,
|
|
// flag indicating whether requests should be made with application-only auth
|
|
app_only_auth : config.app_only_auth,
|
|
}
|
|
|
|
this._validateConfigOrThrow(config);
|
|
this.config = config;
|
|
this._twitter_time_minus_local_time_ms = 0;
|
|
}
|
|
|
|
Twitter.prototype.get = function (path, params, callback) {
|
|
return this.request('GET', path, params, callback)
|
|
}
|
|
|
|
Twitter.prototype.post = function (path, params, callback) {
|
|
return this.request('POST', path, params, callback)
|
|
}
|
|
|
|
Twitter.prototype.request = function (method, path, params, callback) {
|
|
var self = this;
|
|
assert(method == 'GET' || method == 'POST');
|
|
// if no `params` is specified but a callback is, use default params
|
|
if (typeof params === 'function') {
|
|
callback = params
|
|
params = {}
|
|
}
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
var _returnErrorToUser = function (err) {
|
|
if (callback && typeof callback === 'function') {
|
|
callback(err, null, null);
|
|
}
|
|
reject(err);
|
|
}
|
|
|
|
self._buildReqOpts(method, path, params, false, function (err, reqOpts) {
|
|
if (err) {
|
|
_returnErrorToUser(err);
|
|
return
|
|
}
|
|
|
|
var twitOptions = (params && params.twit_options) || {};
|
|
|
|
process.nextTick(function () {
|
|
// ensure all HTTP i/o occurs after the user has a chance to bind their event handlers
|
|
self._doRestApiRequest(reqOpts, twitOptions, method, function (err, parsedBody, resp) {
|
|
self._updateClockOffsetFromResponse(resp);
|
|
|
|
if (self.config.trusted_cert_fingerprints) {
|
|
if (!resp.socket.authorized) {
|
|
// The peer certificate was not signed by one of the authorized CA's.
|
|
var authErrMsg = resp.socket.authorizationError.toString();
|
|
var err = helpers.makeTwitError('The peer certificate was not signed; ' + authErrMsg);
|
|
_returnErrorToUser(err);
|
|
return;
|
|
}
|
|
var fingerprint = resp.socket.getPeerCertificate().fingerprint;
|
|
var trustedFingerprints = self.config.trusted_cert_fingerprints;
|
|
if (trustedFingerprints.indexOf(fingerprint) === -1) {
|
|
var errMsg = util.format('Certificate untrusted. Trusted fingerprints are: %s. Got fingerprint: %s.',
|
|
trustedFingerprints.join(','), fingerprint);
|
|
var err = new Error(errMsg);
|
|
_returnErrorToUser(err);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (callback && typeof callback === 'function') {
|
|
callback(err, parsedBody, resp);
|
|
}
|
|
|
|
resolve({ data: parsedBody, resp: resp });
|
|
return;
|
|
})
|
|
})
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Uploads a file to Twitter via the POST media/upload (chunked) API.
|
|
* Use this as an easier alternative to doing the INIT/APPEND/FINALIZE commands yourself.
|
|
* Returns the response from the FINALIZE command, or if an error occurs along the way,
|
|
* the first argument to `cb` will be populated with a non-null Error.
|
|
*
|
|
*
|
|
* `params` is an Object of the form:
|
|
* {
|
|
* file_path: String // Absolute path of file to be uploaded.
|
|
* }
|
|
*
|
|
* @param {Object} params options object (described above).
|
|
* @param {cb} cb callback of the form: function (err, bodyObj, resp)
|
|
*/
|
|
Twitter.prototype.postMediaChunked = function (params, cb) {
|
|
var self = this;
|
|
try {
|
|
var fileUploader = new FileUploader(params, self);
|
|
} catch(err) {
|
|
cb(err);
|
|
return;
|
|
}
|
|
fileUploader.upload(cb);
|
|
}
|
|
|
|
Twitter.prototype._updateClockOffsetFromResponse = function (resp) {
|
|
var self = this;
|
|
if (resp && resp.headers && resp.headers.date &&
|
|
new Date(resp.headers.date).toString() !== 'Invalid Date'
|
|
) {
|
|
var twitterTimeMs = new Date(resp.headers.date).getTime()
|
|
self._twitter_time_minus_local_time_ms = twitterTimeMs - Date.now();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds and returns an options object ready to pass to `request()`
|
|
* @param {String} method "GET" or "POST"
|
|
* @param {String} path REST API resource uri (eg. "statuses/destroy/:id")
|
|
* @param {Object} params user's params object
|
|
* @param {Boolean} isStreaming Flag indicating if it's a request to the Streaming API (different endpoint)
|
|
* @returns {Undefined}
|
|
*
|
|
* Calls `callback` with Error, Object where Object is an options object ready to pass to `request()`.
|
|
*
|
|
* Returns error raised (if any) by `helpers.moveParamsIntoPath()`
|
|
*/
|
|
Twitter.prototype._buildReqOpts = function (method, path, params, isStreaming, callback) {
|
|
var self = this
|
|
if (!params) {
|
|
params = {}
|
|
}
|
|
// clone `params` object so we can modify it without modifying the user's reference
|
|
var paramsClone = JSON.parse(JSON.stringify(params))
|
|
// convert any arrays in `paramsClone` to comma-seperated strings
|
|
var finalParams = this.normalizeParams(paramsClone)
|
|
delete finalParams.twit_options
|
|
|
|
// the options object passed to `request` used to perform the HTTP request
|
|
var reqOpts = {
|
|
headers: {
|
|
'Accept': '*/*',
|
|
'User-Agent': 'twit-client'
|
|
},
|
|
gzip: true,
|
|
encoding: null,
|
|
}
|
|
|
|
if (typeof self.config.timeout_ms !== 'undefined') {
|
|
reqOpts.timeout = self.config.timeout_ms;
|
|
}
|
|
|
|
try {
|
|
// finalize the `path` value by building it using user-supplied params
|
|
path = helpers.moveParamsIntoPath(finalParams, path)
|
|
} catch (e) {
|
|
callback(e, null, null)
|
|
return
|
|
}
|
|
|
|
if (isStreaming) {
|
|
// This is a Streaming API request.
|
|
|
|
var stream_endpoint_map = {
|
|
user: endpoints.USER_STREAM,
|
|
site: endpoints.SITE_STREAM
|
|
}
|
|
var endpoint = stream_endpoint_map[path] || endpoints.PUB_STREAM
|
|
reqOpts.url = endpoint + path + '.json'
|
|
} else {
|
|
// This is a REST API request.
|
|
|
|
if (path.indexOf('media/') !== -1) {
|
|
// For media/upload, use a different endpoint.
|
|
reqOpts.url = endpoints.MEDIA_UPLOAD + path + '.json';
|
|
} else {
|
|
reqOpts.url = endpoints.REST_ROOT + path + '.json';
|
|
}
|
|
|
|
if (FORMDATA_PATHS.indexOf(path) !== -1) {
|
|
reqOpts.headers['Content-type'] = 'multipart/form-data';
|
|
reqOpts.form = finalParams;
|
|
// set finalParams to empty object so we don't append a query string
|
|
// of the params
|
|
finalParams = {};
|
|
} else if (JSONPAYLOAD_PATHS.indexOf(path) !== -1) {
|
|
reqOpts.headers['Content-type'] = 'application/json';
|
|
reqOpts.json = true;
|
|
reqOpts.body = finalParams;
|
|
// as above, to avoid appending query string for body params
|
|
finalParams = {};
|
|
} else {
|
|
reqOpts.headers['Content-type'] = 'application/json';
|
|
}
|
|
}
|
|
|
|
if (Object.keys(finalParams).length) {
|
|
// not all of the user's parameters were used to build the request path
|
|
// add them as a query string
|
|
var qs = helpers.makeQueryString(finalParams)
|
|
reqOpts.url += '?' + qs
|
|
}
|
|
|
|
if (!self.config.app_only_auth) {
|
|
// with user auth, we can just pass an oauth object to requests
|
|
// to have the request signed
|
|
var oauth_ts = Date.now() + self._twitter_time_minus_local_time_ms;
|
|
|
|
reqOpts.oauth = {
|
|
consumer_key: self.config.consumer_key,
|
|
consumer_secret: self.config.consumer_secret,
|
|
token: self.config.access_token,
|
|
token_secret: self.config.access_token_secret,
|
|
timestamp: Math.floor(oauth_ts/1000).toString(),
|
|
}
|
|
|
|
callback(null, reqOpts);
|
|
return;
|
|
} else {
|
|
// we're using app-only auth, so we need to ensure we have a bearer token
|
|
// Once we have a bearer token, add the Authorization header and return the fully qualified `reqOpts`.
|
|
self._getBearerToken(function (err, bearerToken) {
|
|
if (err) {
|
|
callback(err, null)
|
|
return
|
|
}
|
|
|
|
reqOpts.headers['Authorization'] = 'Bearer ' + bearerToken;
|
|
callback(null, reqOpts)
|
|
return
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make HTTP request to Twitter REST API.
|
|
* @param {Object} reqOpts options object passed to `request()`
|
|
* @param {Object} twitOptions
|
|
* @param {String} method "GET" or "POST"
|
|
* @param {Function} callback user's callback
|
|
* @return {Undefined}
|
|
*/
|
|
Twitter.prototype._doRestApiRequest = function (reqOpts, twitOptions, method, callback) {
|
|
var request_method = request[method.toLowerCase()];
|
|
var req = request_method(reqOpts);
|
|
|
|
var body = '';
|
|
var response = null;
|
|
|
|
var onRequestComplete = function () {
|
|
if (body !== '') {
|
|
try {
|
|
body = JSON.parse(body)
|
|
} catch (jsonDecodeError) {
|
|
// there was no transport-level error, but a JSON object could not be decoded from the request body
|
|
// surface this to the caller
|
|
var err = helpers.makeTwitError('JSON decode error: Twitter HTTP response body was not valid JSON')
|
|
err.statusCode = response ? response.statusCode: null;
|
|
err.allErrors.concat({error: jsonDecodeError.toString()})
|
|
callback(err, body, response);
|
|
return
|
|
}
|
|
}
|
|
|
|
if (typeof body === 'object' && (body.error || body.errors)) {
|
|
// we got a Twitter API-level error response
|
|
// place the errors in the HTTP response body into the Error object and pass control to caller
|
|
var err = helpers.makeTwitError('Twitter API Error')
|
|
err.statusCode = response ? response.statusCode: null;
|
|
helpers.attachBodyInfoToError(err, body);
|
|
callback(err, body, response);
|
|
return
|
|
}
|
|
|
|
// success case - no errors in HTTP response body
|
|
callback(err, body, response)
|
|
}
|
|
|
|
req.on('response', function (res) {
|
|
response = res
|
|
// read data from `request` object which contains the decompressed HTTP response body,
|
|
// `response` is the unmodified http.IncomingMessage object which may contain compressed data
|
|
req.on('data', function (chunk) {
|
|
body += chunk.toString('utf8')
|
|
})
|
|
// we're done reading the response
|
|
req.on('end', function () {
|
|
onRequestComplete()
|
|
})
|
|
})
|
|
|
|
req.on('error', function (err) {
|
|
// transport-level error occurred - likely a socket error
|
|
if (twitOptions.retry &&
|
|
STATUS_CODES_TO_ABORT_ON.indexOf(err.statusCode) !== -1
|
|
) {
|
|
// retry the request since retries were specified and we got a status code we should retry on
|
|
self.request(method, path, params, callback);
|
|
return;
|
|
} else {
|
|
// pass the transport-level error to the caller
|
|
err.statusCode = null
|
|
err.code = null
|
|
err.allErrors = [];
|
|
helpers.attachBodyInfoToError(err, body)
|
|
callback(err, body, response);
|
|
return;
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates/starts a connection object that stays connected to Twitter's servers
|
|
* using Twitter's rules.
|
|
*
|
|
* @param {String} path Resource path to connect to (eg. "statuses/sample")
|
|
* @param {Object} params user's params object
|
|
* @return {StreamingAPIConnection} [description]
|
|
*/
|
|
Twitter.prototype.stream = function (path, params) {
|
|
var self = this;
|
|
var twitOptions = (params && params.twit_options) || {};
|
|
|
|
var streamingConnection = new StreamingAPIConnection()
|
|
self._buildReqOpts('POST', path, params, true, function (err, reqOpts) {
|
|
if (err) {
|
|
// we can get an error if we fail to obtain a bearer token or construct reqOpts
|
|
// surface this on the streamingConnection instance (where a user may register their error handler)
|
|
streamingConnection.emit('error', err)
|
|
return
|
|
}
|
|
// set the properties required to start the connection
|
|
streamingConnection.reqOpts = reqOpts
|
|
streamingConnection.twitOptions = twitOptions
|
|
|
|
process.nextTick(function () {
|
|
streamingConnection.start()
|
|
})
|
|
})
|
|
|
|
return streamingConnection
|
|
}
|
|
|
|
/**
|
|
* Gets bearer token from cached reference on `self`, or fetches a new one and sets it on `self`.
|
|
*
|
|
* @param {Function} callback Function to invoke with (Error, bearerToken)
|
|
* @return {Undefined}
|
|
*/
|
|
Twitter.prototype._getBearerToken = function (callback) {
|
|
var self = this;
|
|
if (self._bearerToken) {
|
|
return callback(null, self._bearerToken)
|
|
}
|
|
|
|
helpers.getBearerToken(self.config.consumer_key, self.config.consumer_secret,
|
|
function (err, bearerToken) {
|
|
if (err) {
|
|
// return the fully-qualified Twit Error object to caller
|
|
callback(err, null);
|
|
return;
|
|
}
|
|
self._bearerToken = bearerToken;
|
|
callback(null, self._bearerToken);
|
|
return;
|
|
})
|
|
}
|
|
|
|
Twitter.prototype.normalizeParams = function (params) {
|
|
var normalized = params
|
|
if (params && typeof params === 'object') {
|
|
Object.keys(params).forEach(function (key) {
|
|
var value = params[key]
|
|
// replace any arrays in `params` with comma-separated string
|
|
if (Array.isArray(value))
|
|
normalized[key] = value.join(',')
|
|
})
|
|
} else if (!params) {
|
|
normalized = {}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
Twitter.prototype.setAuth = function (auth) {
|
|
var self = this
|
|
var configKeys = [
|
|
'consumer_key',
|
|
'consumer_secret',
|
|
'access_token',
|
|
'access_token_secret'
|
|
];
|
|
|
|
// update config
|
|
configKeys.forEach(function (k) {
|
|
if (auth[k]) {
|
|
self.config[k] = auth[k]
|
|
}
|
|
})
|
|
this._validateConfigOrThrow(self.config);
|
|
}
|
|
|
|
Twitter.prototype.getAuth = function () {
|
|
return this.config
|
|
}
|
|
|
|
//
|
|
// Check that the required auth credentials are present in `config`.
|
|
// @param {Object} config Object containing credentials for REST API auth
|
|
//
|
|
Twitter.prototype._validateConfigOrThrow = function (config) {
|
|
//check config for proper format
|
|
if (typeof config !== 'object') {
|
|
throw new TypeError('config must be object, got ' + typeof config)
|
|
}
|
|
|
|
if (typeof config.timeout_ms !== 'undefined' && isNaN(Number(config.timeout_ms))) {
|
|
throw new TypeError('Twit config `timeout_ms` must be a Number. Got: ' + config.timeout_ms + '.');
|
|
}
|
|
|
|
if (config.app_only_auth) {
|
|
var auth_type = 'app-only auth'
|
|
var required_keys = required_for_app_auth
|
|
} else {
|
|
var auth_type = 'user auth'
|
|
var required_keys = required_for_user_auth
|
|
}
|
|
|
|
required_keys.forEach(function (req_key) {
|
|
if (!config[req_key]) {
|
|
var err_msg = util.format('Twit config must include `%s` when using %s.', req_key, auth_type)
|
|
throw new Error(err_msg)
|
|
}
|
|
})
|
|
}
|
|
|
|
module.exports = Twitter
|