struct/EmoteFetcher.js

import BTTVEmote from './BTTVEmote.js'
import Channel from './Channel.js'
import Collection from '../util/Collection.js'
import Constants from '../util/Constants.js'
import FFZEmote from './FFZEmote.js'
import SevenTVEmote from './SevenTVEmote.js'
import TwitchEmote from './TwitchEmote.js'

import { ApiClient } from '@twurple/api'
import { AppTokenAuthProvider } from '@twurple/auth'

class EmoteFetcher {
  /**
   * Fetches and caches emotes.
   * @param {object} [options={}] Fetcher's options.
   * @param {string} [options.twitchAppID] Your app ID for the Twitch API.
   * @param {string} [options.twitchAppSecret] Your app secret for the Twitch API.
   * @param {ApiClient} [options.apiClient] - Bring your own Twurple ApiClient.
   * @param {boolean} [options.forceStatic=false] - Force emotes to be static (non-animated).
   * @param {'dark' | 'light'} [options.twitchThemeMode='dark'] - Theme mode (background color) preference for Twitch emotes.
   */
  constructor (options = {}) {
    if (options.apiClient) {
      /**
       * Provided Twitch ApiClient.
       * @type {ApiClient}
       */
      this.apiClient = options.apiClient
    } else if (options.twitchAppID && options.twitchAppSecret) {
      const authProvider = new AppTokenAuthProvider(options.twitchAppID, options.twitchAppSecret)
      /**
       * Twitch API client.
       * @type {ApiClient}
       */
      this.apiClient = new ApiClient({ authProvider })
    }

    /**
     * Force emotes to be static (non-animated).
     * @type {boolean}
     */
    this.forceStatic = options.forceStatic || false

    /**
     * Theme mode (background color) preference for Twitch emotes.
     * @type {'dark' | 'light'}
     */
    this.twitchThemeMode = options.twitchThemeMode || 'dark'

    /**
     * Cached emotes.
     * Collectionped by emote code to Emote instance.
     * @type {Collection<string, Emote>}
     */
    this.emotes = new Collection()

    /**
     * Cached channels.
     * Collectionped by name to Channel instance.
     * @type {Collection<string, Channel>}
     */
    this.channels = new Collection()

    /**
     * Save if we fetched FFZ's modifier emotes once.
     * @type {boolean}
     */
    this.ffzModifiersFetched = false
  }

  /**
   * The global channel for Twitch, BTTV and 7TV.
   * @readonly
   * @type {?Channel}
   */
  get globalChannel () {
    return this.channels.get(null)
  }

  /**
   * Sets up a channel
   * @private
   * @param {number} channelId - ID of the channel.
   * @param {string} [format] - The type file format to use (webp/avif).
   * @throws {Error} When Twitch Client ID or Client Secret were not provided.
   * @returns {Channel} - A Channel instance.
   */
  _setupChannel (channelId, format) {
    let channel = this.channels.get(channelId)
    if (!channel) {
      channel = new Channel(this, channelId)
      this.channels.set(channelId, channel)
    }
    if (format) channel.format = format.toLowerCase()
    return channel
  }

  /**
   * Gets the raw Twitch emotes data for a channel.
   * @private
   * @param {number} id - ID of the channel.
   * @returns {Promise<object[]>} - A promise that resolves to an array of raw Twitch emote data.
   */
  _getRawTwitchEmotes (id) {
    if (!this.apiClient) {
      throw new Error('Client id or client secret not provided.')
    }

    if (id) {
      return this.apiClient.chat.getChannelEmotes(id)
    } else {
      return this.apiClient.chat.getGlobalEmotes()
    }
  }

  /**
   * Converts and caches a raw twitch emote.
   * @private
   * @param {number} channelId - ID of the channel.
   * @param {object} data - Raw data.
   * @param {TwitchEmote} [existingEmote] - Existing emote to cache.
   * @returns {TwitchEmote} - A TwitchEmote instance.
   */
  _cacheTwitchEmote (channelId, data, existingEmote) {
    const channel = this._setupChannel(channelId)
    const emote = existingEmote || new TwitchEmote(channel, data.id, data)
    this.emotes.set(emote.code, emote)
    channel.emotes.set(emote.code, emote)
    return emote
  }

  /**
   * Gets the raw BTTV emotes data for a channel.
   * Use `null` for the global emotes channel.
   * @private
   * @param {number} [id] - ID of the channel.
   * @returns {Promise<object[]>} - A promise that resolves to an array of raw BTTV emote data.
   */
  _getRawBTTVEmotes (id) {
    const endpoint = id
      ? Constants.BTTV.Channel(id)
      : Constants.BTTV.Global

    return fetch(endpoint)
      .then((response) => response.json())
      .then((data) => {
        // Global emotes
        if (Array.isArray(data)) return data
        // Channel emotes
        if (data?.channelEmotes && data?.sharedEmotes) {
          return [
            ...data.channelEmotes,
            ...data.sharedEmotes,
          ]
        }
        // Fallback - in case the response format is unexpected
        return data || []
      })
  }

  /**
   * Converts and caches a raw BTTV emote.
   * @private
   * @param {number} channelId - ID of the channel.
   * @param {object} data - Raw data.
   * @param {BTTVEmote} [existingEmote] - Existing emote to cache.
   * @returns {BTTVEmote} - A BTTVEmote instance.
   */
  _cacheBTTVEmote (channelId, data, existingEmote) {
    const channel = this._setupChannel(channelId)
    const emote = existingEmote || new BTTVEmote(channel, data.id, data)
    this.emotes.set(emote.code, emote)
    channel.emotes.set(emote.code, emote)
    return emote
  }

  /**
   * Gets the raw FFZ emote data from a set.
   * @private
   * @param {number} id - ID of the set.
   * @returns {Promise<object[]>} - A promise that resolves to an array of raw FFZ emote data.
   */
  _getRawFFZEmoteSet (id) {
    const endpoint = Constants.FFZ.Set(id)

    return fetch(endpoint)
      .then((response) => response.json())
      .then((data) => {
        return data.set.emoticons
      })
  }

  /**
   * Gets the raw FFZ emotes data for a channel.
   * @private
   * @param {number} id - ID of the channel.
   * @returns {Promise<object[]>} - A promise that resolves to an array of raw FFZ emote data.
   */
  _getRawFFZEmotes (id) {
    const endpoint = Constants.FFZ.Channel(id)

    return fetch(endpoint)
      .then((response) => response.json())
      .then((data) => {
        const emotes = []
        for (const key of Object.keys(data.sets)) {
          const set = data.sets[key]
          emotes.push(...set.emoticons)
        }

        return emotes
      })
  }

  /**
   * Converts and caches a raw FFZ emote.
   * @private
   * @param {number} channelId - ID of the channel.
   * @param {object} data - Raw data.
   * @param {FFZEmote} [existingEmote] - Existing emote to cache.
   * @returns {FFZEmote} - A FFZEmote instance.
   */
  _cacheFFZEmote (channelId, data, existingEmote) {
    const channel = this._setupChannel(channelId)
    const emote = existingEmote || new FFZEmote(channel, data.id, data)
    this.emotes.set(emote.code, emote)
    channel.emotes.set(emote.code, emote)
    return emote
  }

  /**
   * Gets the raw 7TV emotes data for a channel.
   * @private
   * @param {number} [id] - ID of the channel.
   * @returns {Promise<object[]>} - A promise that resolves to an array of raw 7TV emote data.
   */
  _getRawSevenTVEmotes (id) {
    const endpoint = id
      ? Constants.SevenTV.Channel(id)
      : Constants.SevenTV.Global

    return fetch(endpoint).then((response) => response.json())
  }

  /**
   * Converts and caches a raw 7TV emote.
   * @private
   * @param {number} channelId - ID of the channel.
   * @param {object} data - Raw data.
   * @param {string} format - The type file format to use (webp/avif).
   * @param {SevenTVEmote} [existingEmote] - Existing emote to cache.
   * @returns {SevenTVEmote} - A SevenTVEmote instance.
   */
  _cacheSevenTVEmote (channelId, data, format, existingEmote) {
    const channel = this._setupChannel(channelId, format)
    const emote = existingEmote || new SevenTVEmote(channel, data.id, data)
    this.emotes.set(emote.code, emote)
    channel.emotes.set(emote.code, emote)
    return emote
  }

  /**
   * Fetches the Twitch emotes for a channel.
   * Use `null` for the global emotes channel.
   * @param {number} [channel] - ID of the channel.
   * @returns {Promise<Collection<string, TwitchEmote>>} - A promise that resolves to a collection of TwitchEmotes.
   */
  fetchTwitchEmotes (channel) {
    return this._getRawTwitchEmotes(channel).then((rawEmotes) => {
      for (const emote of rawEmotes) {
        this._cacheTwitchEmote(channel, {
          code: emote.name, id: emote.id, formats: emote.formats,
        })
      }

      return this.channels.get(channel).emotes.filter((e) => e.type === 'twitch')
    })
  }

  /**
   * Fetches the BTTV emotes for a channel.
   * Use `null` for the global emotes channel.
   * @param {number} [channel] - ID of the channel.
   * @returns {Promise<Collection<string, BTTVEmote>>} - A promise that resolves to a collection of BTTVEmotes.
   */
  fetchBTTVEmotes (channel) {
    return this._getRawBTTVEmotes(channel).then((rawEmotes) => {
      for (const data of rawEmotes) {
        this._cacheBTTVEmote(channel, data)
      }

      return this.channels.get(channel).emotes.filter((e) => e.type === 'bttv')
    })
  }

  /**
   * Fetches the FFZ emotes for a channel.
   * @param {number} [channel] - ID of the channel.
   * @returns {Promise<Collection<string, FFZEmote>>} - A promise that resolves to a collection of FFZEmotes.
   */
  async fetchFFZEmotes (channel) {
    // Fetch modifier emotes at least once
    if (!this.ffzModifiersFetched) {
      this.ffzModifiersFetched = true

      await this._getRawFFZEmoteSet(Constants.FFZ.sets.Modifiers).then((rawEmotes) => {
        for (const data of rawEmotes) {
          this._cacheFFZEmote(null, data)
        }
      })
    }

    // If no channel specified, fetch the Global set
    if (!channel) {
      return this._getRawFFZEmoteSet(Constants.FFZ.sets.Global).then((rawEmotes) => {
        for (const data of rawEmotes) {
          this._cacheFFZEmote(channel, data)
        }

        return this.channels.get(channel).emotes.filter((e) => e.type === 'ffz')
      })
    }

    return this._getRawFFZEmotes(channel).then((rawEmotes) => {
      for (const data of rawEmotes) {
        this._cacheFFZEmote(channel, data)
      }

      return this.channels.get(channel).emotes.filter((e) => e.type === 'ffz')
    })
  }

  /**
   * Fetches the 7TV emotes for a channel.
   * @param {number} [channel] - ID of the channel.
   * @param {object} [options] - Options for fetching.
   * @param {('webp'|'avif')} [options.format] - The type file format to use (webp/avif).
   * @returns {Promise<Collection<string, SevenTVEmote>>} - A promise that resolves to a collection of SevenTVEmotes.
   */
  fetchSevenTVEmotes (channel, options) {
    const {
      format = 'webp',
    } = options || {}

    return this._getRawSevenTVEmotes(channel).then((rawEmotes) => {
      if (Object.hasOwn(rawEmotes, 'emotes')) {
        // From an emote set (like "global")
        for (const data of rawEmotes.emotes) {
          this._cacheSevenTVEmote(channel, data, format)
        }
      } else {
        // From users
        for (const data of rawEmotes.emote_set.emotes) {
          this._cacheSevenTVEmote(channel, data, format)
        }
      }

      return this.channels.get(channel).emotes.filter((e) => e.type === '7tv')
    })
  }

  /**
   * Converts emote Objects to emotes
   * @param {object} [emotesArray] - An array of emote objects
   * @throws {TypeError} When an emote has an unknown type.
   * @returns {Emote[]} - An array of Emote instances.
   */
  fromObject (emotesArray) {
    const emotes = []
    const classMap = {
      'bttv': { class: BTTVEmote, cache: (emoteObject, channelId, existingEmote) => this._cacheBTTVEmote(channelId, null, existingEmote) },
      'ffz': { class: FFZEmote, cache: (emoteObject, channelId, existingEmote) => this._cacheFFZEmote(channelId, null, existingEmote) },
      '7tv': { class: SevenTVEmote, cache: (emoteObject, channelId, existingEmote) => this._cacheSevenTVEmote(channelId, null, emoteObject.imageType, existingEmote) },
      'twitch': { class: TwitchEmote, cache: (emoteObject, channelId, existingEmote) => this._cacheTwitchEmote(channelId, null, existingEmote) },
    }

    for (const emoteObject of emotesArray) {
      const { type } = emoteObject
      if (!Object.keys(classMap).includes(type)) {
        throw new TypeError(`Unknown type: ${type}`)
      }

      const emoteClass = classMap[type].class
      this._setupChannel(emoteObject.channel_id, type === '7tv' ? emoteObject.imageType : null)
      const emote = emoteClass.fromObject(emoteObject, this.channels.get(emoteObject.channel_id))
      classMap[type].cache(emoteObject, emoteObject.channel_id, emote)
      emotes.push(emote)
    }
    return emotes
  }
}

export default EmoteFetcher