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