Source: handlers/Twitter.js

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;
  }
}