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 {@linkcode 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 {@linkcode 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 {@linkcode Emote}s.
     * Collectioned by emote code to the {@linkcode Emote} instance.
     * @type {Collection<string, Emote>}
     */
    this.emotes = new Collection()

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

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

  /**
   * 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.
   * @param {'webp' | 'avif'} [format] - The type file format to use (webp/avif).
   * @returns {Promise<object[]>} - A promise that resolves to an array of raw 7TV emote data.
   */
  _getRawSevenTVEmotes (id, format) {
    return fetch(
      Constants.SevenTV.GQL,
      {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
        },
        body: JSON.stringify({
          query: id
            ? Constants.SevenTV.ChannelQuery
            : Constants.SevenTV.GlobalQuery,
          variables: id
            ? { id: String(id), format: [format.toUpperCase()] }
            : { format: [format.toUpperCase()] },
        }),
      }
    )
      .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 {'webp' | 'avif'} 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 {@linkcode TwitchEmote}s.
   */
  fetchTwitchEmotes (channel) {
    // Ensure channel is null, to be consistent with falsy values
    if (!channel) channel = null

    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 {@linkcode BTTVEmote}s.
   */
  fetchBTTVEmotes (channel) {
    // Ensure channel is null, to be consistent with falsy values
    if (!channel) channel = null

    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 {@linkcode FFZEmote}s.
   */
  async fetchFFZEmotes (channel) {
    // Ensure channel is null, to be consistent with falsy values
    if (!channel) channel = null

    // 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 {@linkcode SevenTVEmote}s.
   */
  fetchSevenTVEmotes (channel, options) {
    // Ensure channel is null, to be consistent with falsy values
    if (!channel) channel = null

    const {
      format = 'webp',
    } = options || {}

    return this._getRawSevenTVEmotes(channel, format).then((rawEmotes) => {
      const emoteItems = channel
        ? rawEmotes?.data?.userByConnection?.emote_sets?.find((set) => set.flags === 0)?.emotes
        : rawEmotes?.data?.namedEmoteSet?.emotes

      for (const data of Array.isArray(emoteItems) ? emoteItems : []) {
        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 {@linkcode Emote} instances.
   */
  fromObject (emotesArray) {
    const emotes = []
    const classMap = {
      'bttv': {
        class: BTTVEmote,
        cache: (_, channelId, existingEmote) => this._cacheBTTVEmote(channelId, null, existingEmote),
      },
      'ffz': {
        class: FFZEmote,
        cache: (_, 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: (_, 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