howsmydriving-twitter
Version:
Twitter abstraction for @HowsMyDrivingWA.
615 lines (611 loc) • 29.7 kB
JavaScript
;
var __read = (this && this.__read) || function (o, n) {
var m = typeof Symbol === "function" && o[Symbol.iterator];
if (!m) return o;
var i = m.call(o), r, ar = [], e;
try {
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
}
catch (error) { e = { error: error }; }
finally {
try {
if (r && !r.done && (m = i["return"])) m.call(i);
}
finally { if (e) throw e.error; }
}
return ar;
};
var __spread = (this && this.__spread) || function () {
for (var ar = [], i = 0; i < arguments.length; i++) ar = ar.concat(__read(arguments[i]));
return ar;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getBotUser = exports.GetTweetById = exports.chompTweet = exports.downloadFile = exports.UploadMedia = exports.SendTweets = exports.GetNewDms = exports.GetNewTweets = void 0;
var Twit = require("twit");
// howsmydriving-utils
var howsmydriving_utils_1 = require("howsmydriving-utils");
// legacy commonjs modules
var DownloaderHelper = require('node-downloader-helper').DownloaderHelper;
var fs = require('fs'), mime = require('mime'), soap = require('soap'), temp = require('temp');
var INTER_TWEET_DELAY_MS = process.env.hasOwnProperty('INTER_TWEET_DELAY_MS') &&
howsmydriving_utils_1.CompareNumericStrings(process.env.INTER_TWEET_DELAY_MS, '0') < 0
? parseInt(process.env.INTER_TWEET_DELAY_MS, 10)
: 5000;
var botScreenNameRegexp = new RegExp('@' + process.env.TWITTER_HANDLE + '\\b', 'i');
var __HOWSMYDRIVING_DELIMITER__ = '||';
var config = {
twitter: {
consumer_key: process.env.CONSUMER_KEY,
consumer_secret: process.env.CONSUMER_SECRET,
access_token: process.env.ACCESS_TOKEN,
access_token_secret: process.env.ACCESS_TOKEN_SECRET
}
};
var logging_1 = require("./logging");
var bot_info = {};
// Create the media download temp folder
var media_download_dir = temp.mkdirSync('.mediadownload__');
logging_1.log.debug("Created media download directory " + media_download_dir + ".");
// We need the bot's app id to detect tweets from the bot
getBotUser()
.then(function (user) {
bot_info = user;
logging_1.log.info("Loaded Twitter bot's info: id: " + bot_info.id + " id_str: " + bot_info.id_str + " screen_name: " + bot_info.screen_name + ".");
})
.catch(function (err) {
handleError(err);
});
// Set the screen_name default to be what's in .env files
bot_info.screen_name = process.env.TWITTER_HANDLE
? process.env.TWITTER_HANDLE
: '';
/*
log.info(`config: ${DumpObject(config)}`);
uploadTwitterMedia()
.then((media_id_str: string) => {
log.info(`YAYYYYYYY!!!!!!!!! Got media_id_str: ${media_id_str}.`);
})
.catch((err: Error) => {
log.error(`BOOOOOOO!!!!! Failed to get media_id_str: ${err}`);
});
*/
/*
new Twit(config.twitter),
'https://maps.googleapis.com/maps/api/staticmap?markers=47.58841060132358,-122.30582739649397&zoom=14&size=400x400&key=AIzaSyCK7loPQ04_Ec3uPZIHTPLuTdz1kYU1_xk',
'This is where the collision occurred'
*/
function GetNewTweets(last_mention_id, bot_account_name) {
if (bot_account_name === void 0) { bot_account_name = process.env.TWITTER_HANDLE; }
var T = new Twit(config.twitter);
var maxTweetIdRead = last_mention_id;
return new Promise(function (resolve, reject) {
if (!last_mention_id) {
reject(new Error('ERROR: last_mention_id must be provided.!'));
}
// Collect promises from these operations so they can go in parallel
var twitter_promises = [];
logging_1.log.debug("Checking for tweets greater than " + last_mention_id + ".");
/* Next, let's search for Tweets that mention our bot, starting after the last mention we responded to. */
T.get('search/tweets', {
q: '%40' + bot_account_name,
since_id: last_mention_id,
tweet_mode: 'extended'
}, function (err, data, response) {
if (err) {
reject(err);
return;
}
var tweets_read = [];
if (data.statuses.length) {
/**
* Iterate over each tweet.
*
* The replies can occur concurrently, but the threaded replies to each tweet must,
* within that thread, execute sequentially.
*
* Since each tweet with a mention is processed in parallel, keep track of largest ID
* and write that at the end.
**/
logging_1.log.debug("Found " + data.statuses.length + " tweets.");
data.statuses.forEach(function (status) {
if (howsmydriving_utils_1.CompareNumericStrings(maxTweetIdRead, status.id_str) < 0) {
maxTweetIdRead = status.id_str;
}
/**
* Make sure this isn't a reply to one of the bot's tweets which would
* include the bot screen name in full_text, but only due to replies.
**/
var _a = chompTweet(status), chomped = _a.chomped, chomped_text = _a.chomped_text;
if (!chomped || botScreenNameRegexp.test(chomped_text)) {
/* Don't reply to retweet or our own tweets. */
if (status.hasOwnProperty('retweeted_status')) {
logging_1.log.debug("Ignoring retweet (by " + status.user.screen_name + "): " + status.full_text);
}
else if (status.user.id == bot_info.id) {
logging_1.log.debug('Ignoring our own tweet: ' + status.full_text);
}
else {
logging_1.log.debug("Found " + howsmydriving_utils_1.PrintTweet(status));
var tweet = {
id: status.id,
id_str: status.id_str,
full_text: status.full_text,
user: {
id: status.user.id,
id_str: status.user.id_str,
screen_name: status.user.screen_name
}
};
tweets_read.push(tweet);
}
}
else {
logging_1.log.debug("Ignoring reply (by " + status.user.screen_name + ") that didn't actually reference bot: " + status.full_text);
}
});
}
else {
/* No new mentions since the last time we checked. */
logging_1.log.debug('No new mentions...');
}
var ret = {
tweets: tweets_read,
last_tweet_read_id: maxTweetIdRead
};
resolve(ret);
});
});
}
exports.GetNewTweets = GetNewTweets;
function GetNewDms(last_dm_id, bot_account_name) {
if (bot_account_name === void 0) { bot_account_name = process.env.TWITTER_HANDLE; }
var T = new Twit(config.twitter);
var maxDmIdRead = last_dm_id;
return new Promise(function (resolve, reject) {
if (!last_dm_id) {
reject(new Error('ERROR: last_dm_id must be provided.!'));
}
// Collect promises from these operations so they can go in parallel
var twitter_promises = [];
logging_1.log.debug("Checking for tweets greater than " + last_dm_id + ".");
/* Next, let's search for Tweets that mention our bot, starting after the last mention we responded to. */
var cursor;
do {
T.get('direct_messages/events/list', {
count: 50,
cursor: cursor
}, function (err, data, response) {
if (err) {
reject(err);
return;
}
var dms_read = [];
if (data.events.length) {
/**
* Iterate over each tweet.
*
* The replies can occur concurrently, but the threaded replies to each tweet must,
* within that thread, execute sequentially.
*
* Since each tweet with a mention is processed in parallel, keep track of largest ID
* and write that at the end.
**/
logging_1.log.debug("Found " + data.events.length + " tweets.");
logging_1.log.debug("Response: " + howsmydriving_utils_1.DumpObject(data) + ".");
data.events.forEach(function (event) {
logging_1.log.debug("Found DM: " + howsmydriving_utils_1.DumpObject(event) + ".");
if (howsmydriving_utils_1.CompareNumericStrings('' /*maxDmIdRead*/, '' /*event.id*/) < 0) {
maxDmIdRead = '' /*event.id*/;
}
/**
* Make sure this isn't a reply to one of the bot's tweets which would
* include the bot screen name in full_text, but only due to replies.
**/
/*
const { chomped, chomped_text } = chompTweet(event.msg);
if (!chomped || botScreenNameRegexp.test(chomped_text)) {
// Don't reply to retweet or our own tweets.
if (status.hasOwnProperty('retweeted_status')) {
log.debug(
`Ignoring retweet (by ${status.user.screen_name}): ${status.full_text}`
);
} else if (status.user.id == bot_info.id) {
log.debug('Ignoring our own tweet: ' + status.full_text);
} else {
log.debug(`Found ${PrintTweet(status)}`);
var tweet: ITweet = {
id: status.id,
id_str: status.id_str,
full_text: status.full_text,
user: {
id: status.user.id,
id_str: status.user.id_str,
screen_name: status.user.screen_name
}
};
dms_read.push(tweet);
}
} else {
log.debug(
`Ignoring reply (by ${event.id}) that didn't actually reference bot: ${event.msg}`
);
}
*/
});
}
else {
/* No new mentions since the last time we checked. */
logging_1.log.debug('No new mentions...');
}
var ret = {
dms: dms_read,
last_dm_read_id: maxDmIdRead
};
resolve(ret);
});
} while (cursor);
});
}
exports.GetNewDms = GetNewDms;
function SendTweets(region_name, orig_tweet, tweet_strings) {
logging_1.log.trace("SendTweets: region: " + region_name + ", orig_tweet: " + howsmydriving_utils_1.PrintTweet(orig_tweet) + ", " + tweet_strings.length + " tweets}");
return sendTweetsInternal(new Twit(config.twitter), region_name, orig_tweet, tweet_strings);
}
exports.SendTweets = SendTweets;
function UploadMedia(region_name, url, alt_text) {
return new Promise(function (resolve, reject) {
logging_1.log.trace("UploadMedia: region: " + region_name + ", url: " + url + ", alt_text: " + alt_text);
var T = new Twit(config.twitter);
uploadTwitterMedia(T, url, alt_text)
.then(function (media_item) {
logging_1.log.debug("UploadMedia: Successfully uploaded " + url + " for region " + region_name + ". Adding metadata...");
setMediaMetadata(media_item.twitter_media_id_str, alt_text).then(function () {
resolve(media_item);
});
})
.catch(function (err) {
handleError(err);
});
});
}
exports.UploadMedia = UploadMedia;
function sendTweetsInternal(T, region_name, orig_tweet, tweet_strings) {
logging_1.log.trace("sendTweetsInternal: region: " + region_name + ", orig_tweet: " + howsmydriving_utils_1.PrintTweet(orig_tweet) + ", tweet_strings (" + tweet_strings.length + ")");
if (tweet_strings.length == 0) {
// return an promise that is already resolved, ending the recursive
// chain of promises that have been built.
return Promise.resolve(0);
}
// Clone the tweet_strings array so we don't modify the one passed to us
var tweet_strings_clone = __spread(tweet_strings);
var tweet_string = tweet_strings_clone.shift();
// When doing the initial reply to the user's tweet, we need to include their
// twitter account in the text of the tweet (i.e. @orig_tweet.user.screen_name).
// But when replying to our own replies, we should not include our own mention
// or else those tweets will show up in the timelines of everyone who
// follows the bot.
if (orig_tweet &&
orig_tweet.user &&
orig_tweet.user.id_str &&
!IsMe(orig_tweet.user.id_str)) {
tweet_string = '@' + orig_tweet.user.screen_name + ' ' + tweet_string;
}
// Check if there are images included with this tweet
var media_items = [];
var parts = tweet_string.split(__HOWSMYDRIVING_DELIMITER__);
if (parts.length > 1) {
// There are images specified for the tweet
media_items = howsmydriving_utils_1.MediaItemsFromString(parts[1]);
tweet_string = parts[0];
}
if (tweet_string.length >= 279) {
throw new Error("too long: " + tweet_string + ", orig_tweet: " + howsmydriving_utils_1.PrintTweet(orig_tweet) + ".");
}
return new Promise(function (resolve, reject) {
var tweets_sent = 0;
logging_1.log.debug("Replying to tweet '" + (orig_tweet && orig_tweet.id_str ? orig_tweet.id_str : 'none') + "': " + tweet_string.trunc(100) + ".");
// There will be one thread running this for each request we are
// processing. We need to make sure we don't send tweets in quick
// succession or Twitter will tag them as spam and they won't
// render i the thread of resposes.
// So wait at least INTER_TWEET_DELAY_MS ms between posts.
var params = {
status: tweet_string
};
if (orig_tweet) {
params['in_reply_to_status_id'] = orig_tweet.id_str;
}
if (media_items.length > 0) {
params['media_ids'] = media_items.map(function (_a) {
var twitter_media_id_str = _a.twitter_media_id_str;
return twitter_media_id_str;
});
logging_1.log.debug("params[media_ids]: " + params['media_ids'] + ".");
}
T.post('statuses/update', params, function (err, data) {
var twit_error_code = 0;
if (err) {
if (err.hasOwnProperty('code')) {
twit_error_code = err['code'];
}
if (twit_error_code != 187) {
handleError(err);
}
else {
logging_1.log.info("HMDWATwit: got 187 error from Twitter.");
// This appears to be a "status is a duplicate" error which
// means we are trying to resend a tweet we already sent.
// If the tweet we are replying to is not one of ours (i.e. the first
// reply in our reply thread), then fail here because we can retry again
// later and it should eventually succeed once the last tweet gets old
// enough that Twitter no longer treats this as a duplicate.
if (!orig_tweet || !orig_tweet.id || !IsMe(orig_tweet.user.id_str)) {
logging_1.log.info("Returning DuplicateError to indicate this is retryable.");
var duplicate_error = new Error("Duplicate tweet detected. Try again later or add a unique id.");
duplicate_error.name = 'DuplicateError';
reject(duplicate_error);
}
else {
// It seems we started a reply thread and then one of the 2 thru n replies
// was detected as a duplicate by Twitter. It seems this should never happen.
// However, if it does, we are going to just pretend this tweet was successful
// and move on to the next one in the thread. Failing halfway through a reply
// thread would be difficult to recover from in a way that we can correctly
// continue the thread later.
logging_1.log.info("Error 187 but replying to our own tweet id_str: " + orig_tweet.id_str + ". Pretending succeeded since no easy way to retry this.");
// Keep replying to the tweet we were told to reply to.
// This means that in this scenario, if any of the rest of the tweets in this
// thread have not been sent, they will create a new thread off the parent of
// this one.
// Not ideal, but the other alternatives are:
// 1) Query for the previous duplicate tweet and then pass that along
// 2) set all of the replies for this request to be PROCESSED even if they did not
// all get tweeted.
data = orig_tweet;
}
}
}
else {
tweets_sent++;
logging_1.log.debug("Sent tweet for region " + region_name + " in response to id_str: " + (orig_tweet ? orig_tweet.id_str : 'none') + ": " + howsmydriving_utils_1.PrintTweet(data));
}
logging_1.log.debug("Waiting before sending rest of tweets...");
// Wait a bit. It seems tweeting a whackload of tweets in quick succession
// can cause Twitter to think you're a troll bot or something and then some
// of the tweets will not display for users other than the bot account.
// See: https://twittercommunity.com/t/inconsistent-display-of-replies/117318/11
howsmydriving_utils_1.sleep(tweet_strings_clone.length > 0 ? INTER_TWEET_DELAY_MS : 0)
.then(function () {
// Send the rest of the responses. When those are sent, then resolve
// the local Promise.
sendTweetsInternal(T, region_name, data, tweet_strings_clone)
.then(function (tweets_sent_rest) {
tweets_sent += tweets_sent_rest;
resolve(tweets_sent);
})
.catch(function (err) {
handleError(err);
});
})
.catch(function (err) {
handleError(err);
});
});
});
}
function uploadTwitterMedia(T, image_url, alt_text) {
return new Promise(function (resolve, reject) {
logging_1.log.debug("Uploading " + image_url + " with alt_text: '" + alt_text + "'");
downloadFile(image_url)
.then(function (downloadedFileName) {
try {
logging_1.log.debug("Downloaded " + image_url + " to temp file " + downloadedFileName + " which " + (fs.existsSync(downloadedFileName) ? 'exists' : 'does not exist') + ". Uploading to Twitter...");
T.post('media/upload', {
command: 'INIT',
media_type: mime.getType(downloadedFileName),
total_bytes: fs.statSync(downloadedFileName).size
})
.then(function (result) {
// (bodyObj: IMediaUploadResponse, resp: http.IncomingMessage) => {
var media_id_str = result.data.media_id_string;
var isStreamingFile = true;
var isUploading = false;
var segmentIndex = 0;
logging_1.log.debug("Opening file " + downloadedFileName + " which " + (fs.existsSync(downloadedFileName)
? 'exists'
: 'does not exist'));
var fStream = fs.createReadStream(downloadedFileName, {
highWaterMark: 5 * 1024 * 1024
});
var _finalizeMedia = function (media_id_str, cb) {
T.post('media/upload', {
command: 'FINALIZE',
media_id: media_id_str
}, cb);
};
var _checkFinalizeResp = function (err, bodyObj, resp) {
logging_1.log.debug("Finalized media upload for " + image_url + ", media_id_str " + media_id_str + ".");
resolve({
url: image_url,
alt_text: alt_text,
twitter_media_id_str: media_id_str
});
};
fStream.on('data', function (buff) {
logging_1.log.debug("Data media upload for " + image_url + ", media_id_str " + media_id_str + ".");
fStream.pause();
isStreamingFile = false;
isUploading = true;
T.post('media/upload', {
command: 'APPEND',
media_id: media_id_str,
segment_index: segmentIndex,
media: buff.toString('base64')
}, function (err, bodyObj, resp) {
logging_1.log.debug("In T.post callback for media upload for " + image_url + ", media_id_str " + media_id_str + ".");
if (err) {
reject(err);
}
logging_1.log.debug("In T.post callback for media upload for " + image_url + ", media_id_str " + media_id_str + ", setting isUploading to false.");
isUploading = false;
if (!isStreamingFile) {
_finalizeMedia(media_id_str, _checkFinalizeResp);
}
});
});
fStream.on('end', function () {
logging_1.log.debug("On end of T.post media upload for " + image_url + ", media_id_str " + media_id_str + ".");
isStreamingFile = false;
if (!isUploading) {
_finalizeMedia(media_id_str, _checkFinalizeResp);
}
});
})
.catch(function (err) {
reject(err);
})
.finally(function () {
fs.unlink(downloadedFileName, function (err) {
if (err) {
logging_1.log.error("Failed to delete temp file " + downloadedFileName + ": " + err);
throw err;
}
logging_1.log.debug("Temp file " + downloadedFileName + " deleted.");
});
});
}
finally {
logging_1.log.debug("In finally for T.post for media upload for " + image_url + ".");
}
})
.catch(function (err) {
handleError(err);
});
});
}
function setMediaMetadata(media_id_str, alt_text) {
var T = new Twit(config.twitter);
return new Promise(function (resolve, reject) {
var params = {
media_id: media_id_str,
alt_text: {
text: alt_text.trunc(420)
}
};
logging_1.log.trace("Setting alt_text for media item " + media_id_str + " to '" + alt_text + "'...");
T.post('media/metadata/create', params, function (err, data, resp) {
if (err) {
handleError(err);
}
logging_1.log.trace("Successfully set alt_text for media item " + media_id_str + " to '" + alt_text + "'.");
// succeeded
resolve();
});
});
}
function downloadFile(url) {
return new Promise(function (resolve, reject) {
logging_1.log.debug("Uploading " + url + "'");
var filePath = temp.path({ suffix: '.tmp' });
var pathSplit = filePath.split('/');
var fileName = pathSplit[pathSplit.length - 1];
// Download media (is there a way to stream it into Twit media/upload without downloading?
var dl = new DownloaderHelper(url, media_download_dir, {
fileName: fileName
});
logging_1.log.debug("Created DownloaderHelper for " + url + ".'");
dl.on('download', function (downloadInfo) {
return logging_1.log.debug("Download of " + url + " begins: {\n name: " + downloadInfo.fileName + ",\n total: " + downloadInfo.totalSize + "\n }");
})
.on('error', function (err) {
logging_1.log.debug("error: " + err);
handleError(err);
})
.on('retry', function (attempt, opts) {
logging_1.log.debug('Retry Attempt:', attempt + '/' + opts.maxRetries, 'Starts on:', opts.delay / 1000, 'secs');
})
.on('stateChanged', function (state) { return logging_1.log.debug('State: ', state); })
.on('end', function (downloadInfo) {
logging_1.log.debug("Downloaded " + url + " to temp file " + downloadInfo.filePath + " which " + (fs.existsSync(downloadInfo.filePath) ? 'exists' : 'does not exist') + ".");
resolve(downloadInfo.filePath);
});
dl.start();
});
}
exports.downloadFile = downloadFile;
function chompTweet(tweet) {
// Extended tweet objects include the screen name of the tweeting user within the full_text,
// as well as all replied-to screen names in the case of a reply.
// Strip off those because if UserA tweets a license plate and references the bot and then
// UserB replies to UserA's tweet without explicitly referencing the bot, we do not want to
// process that tweet.
var chomped = false;
var text = tweet.full_text;
if (tweet.display_text_range != null &&
tweet.display_text_range.length >= 2 &&
tweet.display_text_range[0] > 0) {
text = tweet.full_text.substring(tweet.display_text_range[0]);
chomped = true;
}
return {
chomped: chomped,
chomped_text: text
};
}
exports.chompTweet = chompTweet;
function handleError(error) {
// Truncate the callstack because only the first few lines are relevant to this code.
var stacktrace = '';
if (error.stack) {
error.stack
.split('\n')
.slice(0, 10)
.join('\n');
}
var formattedError = error.message + "\n" + stacktrace;
logging_1.log.error(formattedError);
throw error;
}
function GetTweetById(id) {
// Quick check to fetch a specific tweet.
var T = new Twit(config.twitter);
return new Promise(function (resolve, reject) {
var retTweet;
logging_1.log.debug("Calling statuses/show/" + id);
T.get("statuses/show/" + id, { tweet_mode: 'extended' }, function (err, tweet, response) {
if (err) {
reject(err);
}
else {
logging_1.log.debug("Returning tweet: " + howsmydriving_utils_1.PrintTweet(tweet) + ".");
resolve(tweet);
}
});
});
}
exports.GetTweetById = GetTweetById;
function getBotUser() {
var T = new Twit(config.twitter);
return new Promise(function (resolve, reject) {
var user_info = {};
T.get('account/verify_credentials', {}, function (err, data, response) {
if (err) {
reject(err);
}
else {
user_info.id = data.id;
user_info.id_str = data.id_str;
user_info.screen_name = data.screen_name;
resolve(user_info);
}
});
});
}
exports.getBotUser = getBotUser;
function IsMe(id_str) {
logging_1.log.trace("IsMe: CompareNumeriStrings: " + id_str + " and " + bot_info.id_str);
var ret = howsmydriving_utils_1.CompareNumericStrings(id_str, bot_info.id_str) == 0;
var result = ret ? 'It is me.' : 'It is not me.';
logging_1.log.trace("Checking if it is me: '" + id_str + "' '" + bot_info.id_str + "'..." + result);
return ret;
}