import Twitter from 'twitter'; import toWatch from '../resources/tweeters.json' with { type: 'json' }; import { logger } from '../utilities/index.js'; import { twiClientInfo, TWITTER_TIMEOUT } from '../utilities/env.js'; const determineTweetType = (tweet) => { if (tweet.in_reply_to_status_id) { return 'reply'; } if (tweet.quoted_status_id) { return 'quote'; } if (tweet.retweeted_status) { return 'retweet'; } return 'tweet'; }; const parseAuthor = (tweet) => ({ name: tweet.user.name, handle: tweet.user.screen_name, url: `https://twitter.com/${tweet.user.screen_name}`, avatar: `${tweet.user.profile_image_url.replace('_normal.jpg', '.jpg')}`, }); const parseQuoted = (tweet, type) => tweet[type] ? { text: tweet[type].full_text, author: { name: tweet[type].user.name, handle: tweet[type].user.screen_name, }, } : undefined; const parseTweet = (tweets, watchable) => { const [tweet] = tweets; const type = determineTweetType(tweet); return { id: `twitter.${watchable.plain}.${type}`, uniqueId: String(tweets[0].id_str), text: tweet.full_text, url: `https://twitter.com/${tweet.user.screen_name}/status/${tweet.id_str}`, mediaUrl: tweet.entities.media ? tweet.entities.media[0].media_url : undefined, isReply: typeof tweet.in_reply_to_status_id !== 'undefined', author: parseAuthor(tweet), quote: parseQuoted(tweet, 'quoted_status'), retweet: parseQuoted(tweet, 'retweeted_status'), createdAt: new Date(tweet.created_at), }; }; /** * Twitter event handler */ export default class TwitterCache { /** * Create a new Twitter self-updating cache * @param {EventEmitter} eventEmitter emitter to push new tweets to */ constructor(eventEmitter) { this.emitter = eventEmitter; this.timeout = TWITTER_TIMEOUT; this.clientInfoValid = twiClientInfo.consumer_key && twiClientInfo.consumer_secret && twiClientInfo.bearer_token; this.initClient(twiClientInfo); } initClient(clientInfo) { try { if (this.clientInfoValid) { this.client = new Twitter(clientInfo); // don't attempt anything else if authentication fails this.toWatch = toWatch; this.currentData = undefined; this.lastUpdated = Date.now() - 60000; this.updateInterval = setInterval(() => this.update(), this.timeout); this.update(); } else { logger.warn(`Twitter client not initialized... invalid token: ${clientInfo.bearer_token}`); } } catch (err) { this.client = undefined; this.clientInfoValid = false; logger.error(err); } } /** * Force the cache to update * @returns {Promise} the currently updating promise. */ async update() { if (!this.clientInfoValid) return undefined; if (!this.toWatch) { logger.verbose('Not processing twitter, no data to watch.'); return undefined; } if (!this.client) { logger.verbose('Not processing twitter, no client to connect.'); return undefined; } this.updating = this.getParseableData(); return this.updating; } /** * Get data able to be parsed from twitter. * @returns {Promise.<Array.<Object>>} Tweets */ async getParseableData() { logger.silly('Starting Twitter update...'); const parsedData = []; try { await Promise.all( this.toWatch.map(async (watchable) => { const tweets = await this.client.get('statuses/user_timeline', { screen_name: watchable.acc_name, tweet_mode: 'extended', count: 1, }); const tweet = parseTweet(tweets, watchable); parsedData.push(tweet); if (tweet.createdAt.getTime() > this.lastUpdated) { this.emitter.emit('tweet', tweet); } }) ); } catch (error) { this.onError(error); } this.lastUpdated = Date.now(); return parsedData; } /** * Handle errors that arise while fetching data from twitter * @param {Error} error twitter error */ onError(error) { if (error[0] && error[0].code === 32) { this.clientInfoValid = false; logger.info('wiping twitter client data, could not authenticate...'); } else { logger.debug(JSON.stringify(error)); } } /** * Get the current data or a promise with the current data * @returns {Promise.<Object> | Object} either the current data * if it's not updating, or the promise returning the new data */ async getData() { if (!this.clientInfoValid) return undefined; if (this.updating) { return this.updating; } return this.currentData; } }