UNPKG

worldstate-emitter

Version:

Event emitter for Warframe worldstate & other events - TypeScript support included

1,282 lines (1,262 loc) 37.4 kB
import EventEmitter, { EventEmitter as EventEmitter$1 } from "node:events"; import RssFeedEmitter from "rss-feed-emitter"; import sanitizeHtml from "sanitize-html"; import { createLogger, format, transports } from "winston"; import Twitter from "twitter"; import wsData from "warframe-worldstate-data"; import { CronJob } from "cron"; //#region resources/rssFeeds.json var rssFeeds_default = [ { "url": "https://forums.warframe.com/forum/38-players-helping-players.xml", "key": "players_helping_players", "defaultAttach": "https://i.imgur.com/cuk4ro9.png" }, { "url": "https://forums.warframe.com/forum/3-pc-update-notes.xml", "key": "forum.updates.pc", "defaultAttach": "https://i.imgur.com/eY1NkzO.png" }, { "url": "https://forums.warframe.com/forum/170-announcements-events.xml", "key": "forum.news", "defaultAttach": "https://i.imgur.com/CNrsc7V.png" }, { "url": "https://forums.warframe.com/forum/123-developer-workshop-update-notes.xml", "key": "forum.workshop" }, { "url": "https://forums.warframe.com/discover/837.xml", "key": "forum.staff.megan", "author": { "name": "[DE]Megan", "url": "https://forums.warframe.com/profile/384139-demegan/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2017_06/ezgif.com-crop.thumb.gif.e510920610e8489a54dd5dbe8b9fd4db.gif" } }, { "url": "https://forums.warframe.com/discover/839.xml", "key": "forum.staff.rebecca", "author": { "name": "[DE]Rebecca", "url": "https://forums.warframe.com/profile/4-derebecca/", "icon_url": "https://content.invisioncic.com/Mwarframe/pages_media/1_PlayerAvatarsInkary.png" } }, { "url": "https://forums.warframe.com/discover/840.xml", "key": "forum.staff.danielle", "author": { "name": "[DE]Danielle", "url": "https://forums.warframe.com/profile/869879-dedanielle/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2018_04/profile.thumb.jpg.fe072e16d5b54892b95030ea410264e7.jpg" } }, { "url": "https://forums.warframe.com/discover/841.xml", "key": "forum.staff.drew", "author": { "name": "[DE]Drew", "url": "https://forums.warframe.com/profile/488958-dedrew/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2016_06/576c323f14e92_AnimatedAvatarMirrored.thumb.gif.f12f2373d0d4b91363647f35b30026e0.gif" } }, { "url": "https://forums.warframe.com/discover/842.xml", "key": "forum.staff.glen", "author": { "name": "[DE]Glen", "url": "https://forums.warframe.com/profile/10-deglen/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2016_05/57474a4312a72_GlensHorse.thumb.png.7b47cb0660f5af2e6101ed84d3192c03.png" } }, { "url": "https://forums.warframe.com/discover/1171.xml", "key": "forum.staff.taylor", "author": { "name": "[DE]taylor", "url": "https://forums.warframe.com/profile/1943322-detaylor/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2017_07/596ceb9b4e182_6FUC4s3%281%29.thumb.png.7045d25fc7b057c70ddcffe14cd1f43e.png" } }, { "url": "https://forums.warframe.com/discover/1777.xml", "key": "forum.staff.steve", "author": { "name": "[DE]Steve", "url": "https://forums.warframe.com/profile/24-desteve/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2016_06/575f13eaf3080_Pastedimageat2016_06_1304_11PM.thumb.png.fb98af24931a1820d00293a55c05baef.png" } }, { "url": "https://forums.warframe.com/discover/1291.xml", "key": "forum.staff.helen", "author": { "name": "[DE]Helen", "url": "https://forums.warframe.com/profile/2522846-dehelen/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2017_06/590e937e598f2_Helen-Icon.jpg.0ac6d3d86c1d48f3f3e1a035344ab87a.thumb.jpg.d2225fb0dcd980f081ddddbf46458072.jpg" } }, { "url": "https://forums.warframe.com/discover/1294.xml", "key": "forum.staff.saske", "author": { "name": "[DE]Saske", "url": "https://forums.warframe.com/profile/4513168-nswdesaske/", "icon_url": "https://content.invisioncic.com/Mwarframe/pages_media/1_TennoCon2018Glyph.png" } }, { "url": "https://forums.warframe.com/discover/1295.xml", "key": "forum.staff.syncrasis", "author": { "name": "[DE]Syncrasis", "url": "https://forums.warframe.com/profile/2514676-desyncrasis//", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2017_02/aladv.thumb.jpg.a06cbfa091579eb9ce2ae96eb0b42e34.jpg" } }, { "url": "https://forums.warframe.com/discover/1299.xml", "key": "forum.staff.pablo", "author": { "name": "[DE]Pablo", "url": "https://forums.warframe.com/profile/217656-depablo/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2016_05/Pablo.thumb.png.35bb0384ef7b88e807d55ffc31af0896.png" } }, { "url": "https://forums.warframe.com/discover/1779.xml", "key": "forum.staff.marcus", "author": { "name": "[DE]Marcus", "url": "https://forums.warframe.com/profile/3443485-demarcus/", "icon_url": "https://content.invisioncic.com/Mwarframe/monthly_2018_01/5a55278d71caa_MarcusIIPrint.thumb.jpg.16cb689d778112333cffb6fe546b89ec.jpg" } } ]; //#endregion //#region utilities/env.ts const LOG_LEVEL = process?.env?.LOG_LEVEL || "error"; const twiClientInfo = { consumer_key: process?.env?.TWITTER_KEY, consumer_secret: process?.env?.TWITTER_SECRET, bearer_token: process?.env?.TWITTER_BEARER_TOKEN }; const TWITTER_TIMEOUT = Number(process?.env?.TWITTER_TIMEOUT) || 6e4; //#endregion //#region utilities/index.ts let tempLogger; try { const { combine, label, printf, colorize } = format; const transport = new transports.Console(); const logFormat = printf((info) => `[${info.label}] ${info.level}: ${info.message}`); tempLogger = createLogger({ format: combine(colorize(), label({ label: "WS" }), logFormat), transports: [transport] }); tempLogger.level = LOG_LEVEL; } catch (_e) { tempLogger = createLogger({ transports: [new transports.Console()] }); } const logger = tempLogger; /** * Group an array by a field value * @param array - array of objects to group * @param field - field to group by * @returns Grouped object */ const groupBy = (array, field) => { const grouped = {}; if (!array) return void 0; for (const item of array) { const fVal = String(item[field]); if (!grouped[fVal]) grouped[fVal] = []; grouped[fVal].push(item); } return grouped; }; const allowedDeviation = 3e4; /** * Validate that b is between a and c * @param a - The first Date, should be the last time things were updated * @param b - The second Date, should be the activation time of an event * @param c - The third Date, should be the start time of this update cycle * @returns if the event date is between the server start time and the last update time */ const between = (a, b, c = Date.now()) => b + allowedDeviation > a && b - allowedDeviation < c; /** * Returns the number of milliseconds between now and a given date * @param d - The date from which the current time will be subtracted * @param now - A function that returns the current UNIX time in milliseconds * @returns Milliseconds from now */ function fromNow(d, now = Date.now) { return new Date(d).getTime() - now(); } /** * Map of last updated dates/times */ const lastUpdated = { pc: { en: 0 }, ps4: { en: Date.now() }, xb1: { en: Date.now() }, swi: { en: Date.now() } }; //#endregion //#region handlers/RSS.ts /** * RSS Emitter, leverages rss-feed-emitter */ var RSS = class { logger = logger; emitter; feeder; startTime; feeds; /** * Set up emitting events for warframe forum entries * @param eventEmitter - Emitter to send events from * @param options - Optional configuration * @param options.autoStart - Whether to automatically start the feeder (default: true) * @param options.feeds - Custom feed list (default: uses rssFeeds.json) * @param options.startTime - Custom start time for filtering old items (default: Date.now()) * @param options.logger - Custom logger instance (default: uses global logger) */ constructor(eventEmitter, options = {}) { this.emitter = eventEmitter; this.feeds = options.feeds || rssFeeds_default; this.startTime = options.startTime ?? Date.now(); if (options.logger) this.logger = options.logger; this.feeder = new RssFeedEmitter({ userAgent: "WFCD Feed Notifier", skipFirstLoad: true }); this.feeder.on("error", this.logger.error.bind(this.logger)); this.feeder.on("new-item", this.handleNew.bind(this)); if (options.autoStart !== false) this.start(); } /** * Start the RSS feed polling */ start() { for (const feed of this.feeds) this.feeder.add({ url: feed.url, refresh: 3e4 }); this.logger.debug("RSS Feed active"); } destroy() { this.feeder.destroy(); this.logger.debug("RSS Feed destroyed"); } /** * Extract image URL from RSS item description * @param description - The RSS item description HTML * @param feed - The feed configuration * @returns The image URL or undefined * @private */ extractImage(description, feed) { const firstImg = ((description || "").match(/<img.*src="(.*)".*>/i) || [])[1]; if (!firstImg) return feed.defaultAttach; if (firstImg.startsWith("//")) return firstImg.replace("//", "https://"); return firstImg; } /** * Find the feed configuration for an RSS item * @param item - The RSS item * @returns The feed configuration or undefined * @private */ findFeed(item) { let feed = this.feeds.find((feedEntry) => feedEntry.url === item.meta.link); if (feed) return feed; const rssLink = item.meta["rss:link"]?.["#"]; if (rssLink) { feed = this.feeds.find((feedEntry) => feedEntry.url === rssLink); if (feed) return feed; } const registeredFeeds = this.feeder.list; if (Array.isArray(registeredFeeds)) for (const registeredFeed of registeredFeeds) { const matchingFeed = this.feeds.find((f) => f.url === registeredFeed.url); if (matchingFeed) return matchingFeed; } this.logger.debug(`No feed found for item: ${item.title} (meta.link: ${item.meta.link})`); } /** * Handle a new RSS item * @param item - The RSS item from the feed * @private */ handleNew(item) { try { if (item.image && Object.keys(item.image).length) this.logger.debug(`Image: ${JSON.stringify(item.image)}`); if (new Date(item.pubDate).getTime() <= this.startTime) return; const feed = this.findFeed(item); if (!feed) return; const firstImg = this.extractImage(item.description, feed); const rssSummary = { body: sanitizeHtml(item.description || "​", { allowedTags: [], allowedAttributes: {} }).replace(/\n\n+\s*/gm, "\n\n"), url: item.link, timestamp: item.pubDate, description: item.meta.description, author: feed.author || { name: "Warframe Forums", url: item.meta["rss:link"]?.["#"] || item.link, icon_url: "https://i.imgur.com/hE2jdpv.png" }, title: item.title, ...firstImg && { image: firstImg }, id: feed.key }; this.emitter.emit("rss", rssSummary); } catch (error) { this.logger.error(error); } } }; //#endregion //#region resources/tweeters.json var tweeters_default = [ { "acc_name": "@playwarframe", "plain": "warframe" }, { "acc_name": "@digitalextremes", "plain": "digitalextremes" }, { "acc_name": "@PabloMakes", "plain": "pablo" }, { "acc_name": "@Cam_Rogers", "plain": "cameron" }, { "acc_name": "@rebbford", "plain": "rebecca" }, { "acc_name": "@sj_sinclair", "plain": "steve" }, { "acc_name": "@soelloo", "plain": "danielle" }, { "acc_name": "@moitoi", "plain": "megan" }, { "acc_name": "@GameSoundDesign", "plain": "george" }, { "acc_name": "@msinilo", "plain": "maciej" }, { "acc_name": "@sheldoncarter", "plain": "sheldon" }, { "acc_name": "@MarcusKretz", "plain": "marcus" }, { "acc_name": "@Helen_Heikkila", "plain": "helen" }, { "acc_name": "@tobitenno", "plain": "tobiah" }, { "acc_name": "@wfdiscord", "plain": "wfdiscord" } ]; //#endregion //#region handlers/Twitter.ts 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 ? tweet.user.profile_image_url.replace("_normal.jpg", ".jpg") : "" }); const parseQuoted = (tweet, type) => tweet[type] ? { text: tweet[type].full_text || tweet[type].text, author: { name: tweet[type].user.name, handle: tweet[type].user.screen_name } } : void 0; const parseTweet = (tweets, watchable) => { if (!tweets.length) throw new Error(`No tweets found for ${watchable.acc_name}`); const [tweet] = tweets; const type = determineTweetType(tweet); return { id: `twitter.${watchable.plain}.${type}`, uniqueId: String(tweets[0].id_str), text: tweet.full_text || tweet.text, url: `https://twitter.com/${tweet.user.screen_name}/status/${tweet.id_str}`, mediaUrl: tweet.entities.media ? tweet.entities.media[0].media_url : void 0, 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 */ var TwitterCache = class { emitter; timeout; clientInfoValid; client; toWatch; currentData; lastUpdated; updateInterval; updating; disposed; /** * Create a new Twitter self-updating cache * @param eventEmitter - Emitter to push new tweets to * @param options - Optional configuration * @param options.autoStart - Whether to automatically start polling (default: true) * @param options.clientInfo - Custom Twitter client credentials * @param options.watchList - Custom list of Twitter accounts to watch * @param options.timeout - Polling interval in milliseconds */ constructor(eventEmitter, options = {}) { this.emitter = eventEmitter; this.timeout = options.timeout ?? TWITTER_TIMEOUT; this.lastUpdated = Date.now() - 6e4; this.disposed = false; const clientInfo = options.clientInfo ?? twiClientInfo; this.clientInfoValid = !!clientInfo.consumer_key && !!clientInfo.consumer_secret && !!clientInfo.bearer_token; if (options.watchList) this.toWatch = options.watchList; if (options.autoStart !== false) this.initClient(clientInfo); } initClient(clientInfo) { try { if (this.clientInfoValid && clientInfo.consumer_key && clientInfo.consumer_secret && clientInfo.bearer_token) { this.client = new Twitter({ consumer_key: clientInfo.consumer_key, consumer_secret: clientInfo.consumer_secret, bearer_token: clientInfo.bearer_token }); if (!this.toWatch) this.toWatch = tweeters_default; this.currentData = void 0; this.lastUpdated = Date.now() - 6e4; this.updateInterval = setInterval(() => this.update(), this.timeout); this.update(); } else { logger.warn(`Twitter client not initialized... invalid token: ${clientInfo.bearer_token}`); this.dispose(); } } catch (err) { logger.error(err); this.dispose(); } } /** * Set a mock Twitter client for testing * @param mockClient - Mock Twitter client * @internal */ setClient(mockClient) { this.client = mockClient; if (!this.toWatch) this.toWatch = tweeters_default; } /** * Force the cache to update * @returns The currently updating promise */ async update() { if (this.disposed || !this.clientInfoValid) return void 0; if (!this.toWatch || this.toWatch.length === 0) { logger.verbose("Not processing twitter, no data to watch."); return; } if (!this.client) { logger.verbose("Not processing twitter, no client to connect."); return; } this.updating = this.getParseableData(); return this.updating; } /** * Get data able to be parsed from twitter. * @returns Tweets */ async getParseableData() { logger.silly("Starting Twitter update..."); const parsedData = []; try { await Promise.all((this.toWatch || []).map(async (watchable) => { const tweet = parseTweet(await this.client.get("statuses/user_timeline", { screen_name: watchable.acc_name, tweet_mode: "extended", count: 1 }), watchable); parsedData.push(tweet); if (tweet.createdAt.getTime() > this.lastUpdated) this.emitter.emit("tweet", tweet); })); this.currentData = parsedData; this.lastUpdated = Date.now(); } catch (error) { this.onError(error); } return parsedData; } /** * Handle errors that arise while fetching data from twitter * @param error - Twitter error */ onError(error) { if (Array.isArray(error) && error[0] && error[0].code === 32) { logger.info("wiping twitter client data, could not authenticate..."); this.dispose(); } else logger.debug(JSON.stringify(error)); } /** * Get the current data or a promise with the current data * @returns Either the current data if it's not updating, or the promise returning the new data */ async getData() { if (!this.clientInfoValid) return void 0; if (this.updating) return this.updating; return this.currentData || []; } /** * Stop polling and clean up resources */ dispose() { this.disposed = true; if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = void 0; } this.client = void 0; this.clientInfoValid = false; logger.verbose("Twitter polling stopped and resources cleaned up"); } }; //#endregion //#region handlers/events/eKeyOverrides.ts const fissures = (data) => { const fissure = data; return `fissures.t${fissure.tierNum}.${(fissure.missionType || "").toLowerCase()}`; }; const enemies = (data) => { const enemy = data; return { eventKey: `enemies${enemy.isDiscovered ? "" : ".departed"}`, activation: new Date(enemy.lastDiscoveredAt) }; }; const arbitration = (data) => { const arbi = data; if (!arbi?.enemy) return ""; let k; try { k = `arbitration.${arbi.enemy.toLowerCase()}.${arbi.type.replace(/\s/g, "").toLowerCase()}`; } catch (e) { logger.error(`Unable to parse arbitration: ${JSON.stringify(arbi)}\n${e}`); return ""; } return k; }; const events = "operations"; const persistentEnemies = "enemies"; const overrides = { fissures, enemies, arbitration, events, persistentEnemies }; //#endregion //#region handlers/events/checkOverrides.ts /** * Find overrides for the provided key * @param key - worldstate field to find overrides * @param data - data corresponding to the key from provided worldstate * @returns overrided key or object */ var checkOverrides_default = (key, data) => { const override = overrides[key]; if (typeof override === "string") return override; if (typeof override === "function") return override(data); return key; }; //#endregion //#region handlers/events/objectLike.ts /** * Process object-like worldstate events * @param data - Event data * @param deps - Dependencies for processing * @returns Packet to emit or undefined */ var objectLike_default = (data, deps) => { if (!data) return void 0; const last = new Date(lastUpdated[deps.platform][deps.language]); const activation = new Date(data.activation ?? 0); const start = new Date(deps.cycleStart ?? 0); if (between(last.getTime(), activation.getTime(), start.getTime())) return { ...deps, data, id: deps.id || deps.key }; }; //#endregion //#region handlers/events/arrayLike.ts /** * arrayLike are all just arrays of objectLike * @param deps - dependencies for processing * @returns object(s) to emit from arrayLike processing */ var arrayLike_default = (deps) => { const newPackets = []; try { for (const arrayItem of deps.data) { const k = checkOverrides_default(deps.key, arrayItem); const result = objectLike_default(arrayItem, { ...deps, data: arrayItem, id: typeof k === "string" ? k : k.eventKey }); if (result) newPackets.push(result); } return newPackets; } catch (err) { logger.error(err); return newPackets; } }; //#endregion //#region handlers/events/cycleLike.ts /** * CycleData parser * @param cycleData - data for parsing all cycles like this * @param deps - dependencies for processing * @returns Array of packets to emit */ var cycleLike_default = (cycleData, deps) => { const packet = { ...deps, data: cycleData, id: `${deps.key.replace("Cycle", "")}.${cycleData.state}` }; const last = new Date(lastUpdated[deps.platform]?.[deps.language] ?? 0); const activation = new Date(cycleData.activation ?? 0); const start = new Date(deps.cycleStart); const packets = []; if (between(last.getTime(), activation.getTime(), start.getTime())) packets.push(packet); if (cycleData.expiry) { const timePacket = { ...packet, id: `${packet.id}.${Math.round(fromNow(cycleData.expiry.toString()) / 6e4)}` }; packets.push(timePacket); } return packets; }; //#endregion //#region handlers/events/kuva.ts /** * Process kuva fields * @param deps - dependencies for processing * @param packets - packets to emit * @returns object(s) to emit from kuva stuff */ var kuva_default = (deps, packets) => { if (!deps.data) { logger.error("no kuva data"); return; } const data = groupBy(deps.data, "type"); if (!data) return void 0; for (const type of Object.keys(data)) { const typeData = data[type]; if (!data[type]?.length) continue; const updatedDeps = { ...deps, data: typeData[0], id: `kuva.${typeData[0].type.replace(/\s/g, "").toLowerCase()}`, activation: typeData[0].activation, expiry: typeData[0].expiry }; const p = objectLike_default(typeData[0], updatedDeps); if (p) packets.push(p); } return packets.filter((p) => p !== void 0); }; //#endregion //#region handlers/events/nightwave.ts /** * Process nightwave challenges * @param nightwave - Nightwave data * @param deps - Dependencies for processing * @returns Array of packets to emit */ var nightwave_default = (nightwave, deps) => { const groups = { daily: [], weekly: [], elite: [] }; for (const challenge of nightwave.activeChallenges || []) if (challenge.isDaily) groups.daily.push(challenge); else if (challenge.isElite) groups.elite.push(challenge); else groups.weekly.push(challenge); const packets = []; for (const group of Object.keys(groups)) { const nightwaveWithGroup = { ...nightwave, activeChallenges: groups[group] }; const p = objectLike_default(nightwaveWithGroup, { ...deps, data: nightwaveWithGroup, id: `nightwave.${group}` }); if (p) packets.push(p); } return packets; }; //#endregion //#region handlers/events/parse.ts /** * Set up current cycle start if it's not been initiated * @param deps - dependencies for processing */ const initCycleStart = (deps) => { if (!lastUpdated[deps.platform][deps.language]) lastUpdated[deps.platform][deps.language] = typeof deps.cycleStart === "number" ? deps.cycleStart : deps.cycleStart.getTime(); }; /** * Parse new events from the provided worldstate * @param deps - dependencies to parse out events * @returns packet(s) to emit */ var parse_default = (deps) => { initCycleStart(deps); const packets = []; switch (deps.key) { case "kuva": { const kuvaData = Array.isArray(deps.data) ? deps.data : [deps.data]; return kuva_default({ ...deps, data: kuvaData }, packets); } case "events": { const eventsOverride = events; const arrayData = Array.isArray(deps.data) ? deps.data : [deps.data]; const updatedDeps = { ...deps, data: arrayData, id: eventsOverride }; packets.push(...arrayLike_default(updatedDeps)); break; } case "alerts": case "conclaveChallenges": case "dailyDeals": case "flashSales": case "fissures": case "globalUpgrades": case "invasions": case "syndicateMissions": case "weeklyChallenges": { const arrayData = Array.isArray(deps.data) ? deps.data : [deps.data]; const arrayDeps = { ...deps, data: arrayData }; packets.push(...arrayLike_default(arrayDeps)); break; } case "cetusCycle": case "earthCycle": case "vallisCycle": { const singleData = Array.isArray(deps.data) ? deps.data[0] : deps.data; const cyclePackets = cycleLike_default(singleData, { ...deps, data: singleData }); packets.push(...cyclePackets); break; } case "persistentEnemies": { const singleData = Array.isArray(deps.data) ? deps.data[0] : deps.data; const overrides = checkOverrides_default(deps.key, singleData); const packet = objectLike_default(singleData, { ...deps, data: singleData, ...typeof overrides === "object" ? overrides : {}, id: typeof overrides === "string" ? overrides : overrides.eventKey }); if (packet) packets.push(packet); break; } case "sortie": case "voidTrader": case "arbitration": case "sentientOutposts": { const singleData = Array.isArray(deps.data) ? deps.data[0] : deps.data; const override = checkOverrides_default(deps.key, singleData); const packet = objectLike_default(singleData, { ...deps, data: singleData, id: typeof override === "string" ? override : override.eventKey }); if (packet) packets.push(packet); break; } case "nightwave": { const singleData = Array.isArray(deps.data) ? deps.data[0] : deps.data; const nightwavePackets = nightwave_default(singleData, { ...deps, data: singleData }); packets.push(...nightwavePackets); break; } default: break; } return packets; }; //#endregion //#region resources/config.ts const worldstateUrl = process.env.WORLDSTATE_URL ?? "https://api.warframe.com/cdn/worldState.php"; const kuvaUrl = process.env.KUVA_URL ?? "https://10o.io/arbitrations.json"; const sentientUrl = process.env.SENTIENT_URL ?? "https://semlar.com/anomaly.json"; const worldstateCron = process.env.WORLDSTATE_CRON ?? "25 */5 * * * *"; const externalCron = process.env.WS_EXTERNAL_CRON ?? "0 */10 * * * *"; const FEATURES = process.env.WS_EMITTER_FEATURES ? process.env.WS_EMITTER_FEATURES.split(",") : []; //#endregion //#region utilities/Cache.ts /** * Cron-based cache that periodically fetches data from a URL */ var CronCache = class CronCache extends EventEmitter$1 { #url; #pattern = "0 */10 * * * *"; #job; #data = ""; #updating = void 0; #logger = logger; /** * Create and initialize a CronCache instance * @param url - The URL to fetch data from * @param pattern - Optional cron pattern for update frequency * @returns Initialized CronCache instance */ static async make(url, pattern) { const cache = new CronCache(url, pattern); await cache.#update(); return cache; } /** * Create a new CronCache * @param url - The URL to fetch data from * @param pattern - Optional cron pattern for update frequency */ constructor(url, pattern) { super(); this.#url = url; if (pattern) this.#pattern = pattern; this.#job = new CronJob(this.#pattern, () => void this.#update(), void 0, true); this.#job.start(); } /** * Update the cached data by fetching from the URL * @private */ async #update() { this.#updating = this.#fetch(); this.#logger.debug(`update starting for ${this.#url}`); let error; try { this.#data = await this.#updating; return this.#updating; } catch (e) { this.#data = void 0; error = e; } finally { if (this.#data) { this.emit("update", this.#data); this.#logger.debug(`update done for ${this.#url}`); } else this.#logger.debug(`update failed for ${this.#url} : ${error}`); this.#updating = void 0; } } /** * Fetch data from the configured URL * @private */ async #fetch() { logger.silly(`fetching... ${this.#url}`); const response = await fetch(this.#url); if (!response.ok) { const responseText = await response.text(); const errorMessage = `Failed to fetch ${this.#url}: ${response.status} ${response.statusText}`; logger.error(errorMessage, { responseText }); throw new Error(errorMessage); } this.#data = await response.text(); return this.#data; } /** * Get the cached data, optionally waiting for an in-progress update * @returns The cached data */ async get() { if (this.#updating) { logger.silly("returning in-progress update promise"); return this.#updating; } if (!this.#data) { logger.silly("returning new update promise"); return this.#update(); } logger.silly("returning cached data"); return this.#data; } /** * Stop the cron job and cleanup */ stop() { this.#job.stop(); this.#logger.debug(`Cron job stopped for ${this.#url}`); } }; //#endregion //#region utilities/WSCache.ts /** * Warframe WorldState Cache - store and retrieve current worldstate data */ var WSCache = class { #inner; #kuvaCache; #sentientCache; #logger = logger; #emitter; #platform = "pc"; #language; /** * Set up a cache checking for data and updates to a specific worldstate set * @param options - Configuration options * @param options.language - Language/translation to track * @param options.kuvaCache - Cache of kuva data, provided by Semlar * @param options.sentientCache - Cache of sentient outpost data, provided by Semlar * @param options.eventEmitter - Emitter to push new worldstate updates to */ constructor({ language, kuvaCache, sentientCache, eventEmitter }) { this.#inner = void 0; this.#kuvaCache = kuvaCache; this.#sentientCache = sentientCache; this.#language = language; this.#emitter = eventEmitter; } /** * Update the current data with new data * @param newData - updated worldstate data * @private */ #update = async (newData) => { const deps = { locale: this.#language, kuvaData: {}, sentientData: {} }; try { const kuvaRaw = await this.#kuvaCache.get(); if (kuvaRaw) deps.kuvaData = JSON.parse(kuvaRaw); } catch (err) { logger.debug(`Error parsing kuva data for ${this.#language}: ${err}`); } try { const sentientRaw = await this.#sentientCache.get(); if (sentientRaw) deps.sentientData = JSON.parse(sentientRaw); } catch (err) { logger.warn(`Error parsing sentient data for ${this.#language}: ${err}`); } let t; try { t = await WorldState.build(newData, deps); if (!t?.timestamp) return; } catch (err) { this.#logger.warn(`Error parsing worldstate data for ${this.#language}: ${err}`); return; } this.#inner = t; this.#emitter.emit("ws:update:parsed", { language: this.#language, platform: this.#platform, data: t }); }; /** * Get the latest worldstate data from this cache * @returns Current worldstate data */ get data() { return this.#inner; } /** * Set the current data, also parses and emits data * @param newData - New string data to parse */ set data(newData) { logger.debug(`got new data for ${this.#language}, parsing...`); this.#update(newData); } /** * Set the current twitter data for the worldstate * @param newTwitter - twitter data */ set twitter(newTwitter) { if (!newTwitter?.length) return; if (this.#inner) this.#inner.twitter = newTwitter; } }; //#endregion //#region handlers/Worldstate.ts const { locales } = wsData; const debugEvents = [ "arbitration", "kuva", "nightwave" ]; /** * Handler for worldstate data */ var Worldstate = class { #emitter; #locale; #worldStates = {}; #wsRawCache; #kuvaCache; #sentientCache; /** * Set up listening for specific platform and locale if provided. * @param eventEmitter - Emitter to push new worldstate events to * @param locale - Locale (actually just language) to watch */ constructor(eventEmitter, locale) { this.#emitter = eventEmitter; this.#locale = locale; logger.debug("starting up worldstate listener..."); if (locale) logger.debug(`only listening for ${locale}...`); } async init() { this.#wsRawCache = await CronCache.make(worldstateUrl, worldstateCron); this.#kuvaCache = await CronCache.make(kuvaUrl, externalCron); this.#sentientCache = await CronCache.make(sentientUrl, externalCron); await this.setUpRawEmitters(); this.setupParsedEvents(); } /** * Set up emitting raw worldstate data */ async setUpRawEmitters() { this.#worldStates = {}; for await (const locale of locales) if (!this.#locale || this.#locale === locale) this.#worldStates[locale] = new WSCache({ language: locale, kuvaCache: this.#kuvaCache, sentientCache: this.#sentientCache, eventEmitter: this.#emitter }); this.#wsRawCache.on("update", (dataStr) => { this.#emitter.emit("ws:update:raw", { platform: "pc", data: dataStr }); }); this.#emitter.on("ws:update:raw", ({ data }) => { logger.debug("ws:update:raw - updating locales data"); locales.forEach((locale) => { if (!this.#locale || this.#locale === locale) this.#worldStates[locale].data = data; }); }); } /** * Set up listeners for the parsed worldstate updates */ setupParsedEvents() { this.#emitter.on("ws:update:parsed", ({ language, platform, data }) => { const packet = { platform, worldstate: data, language }; this.parseEvents(packet); }); } /** * Parse new worldstate events * @param packet - Object containing worldstate, platform, and language */ parseEvents({ worldstate, platform, language = "en" }) { const cycleStart = Date.now(); const packets = []; Object.keys(worldstate).forEach((key) => { const wsRecord = worldstate; if (worldstate && wsRecord[key]) { const packet = parse_default({ data: wsRecord[key], key, language, platform, cycleStart }); if (Array.isArray(packet)) { if (packet.length) packets.push(...packet.filter((p) => p)); } else if (packet) packets.push(packet); } }); lastUpdated[platform][language] = Date.now(); packets.filter((p) => !!p && !!p.id).forEach((packet) => { this.emit("ws:update:event", packet); }); } /** * Emit an event with given id * @param id - Id of the event to emit * @param packet - Data packet to emit */ emit(id, packet) { if (debugEvents.includes(packet.key)) logger.warn(packet.key); logger.debug(`ws:update:event - emitting ${packet.id}`); delete packet.cycleStart; this.#emitter.emit(id, packet); } /** * get a specific worldstate version * @param language - Locale of the worldstate * @returns Worldstate corresponding to provided data * @throws When the platform or locale aren't tracked and aren't updated */ get(language = "en") { logger.debug(`getting worldstate ${language}...`); if (this.#worldStates?.[language]) return this.#worldStates?.[language]?.data; throw new Error(`Language (${language}) not tracked.\nEnsure that the parameters passed are correct`); } destroy() { this.#wsRawCache?.stop(); this.#kuvaCache?.stop(); this.#sentientCache?.stop(); } }; //#endregion //#region index.ts var WorldstateEmitter = class WorldstateEmitter extends EventEmitter { #locale; #worldstate; #twitter; #rss; static async make({ locale, features } = {}) { const emitter = new WorldstateEmitter({ locale }); await emitter.#init(features?.length ? features : FEATURES); return emitter; } /** * Pull in and instantiate emitters * @param options - Configuration options */ constructor({ locale } = {}) { super(); this.#locale = locale; } async #init(features) { if (features.includes("rss")) this.#rss = new RSS(this); if (features.includes("worldstate")) { this.#worldstate = new Worldstate(this, this.#locale); await this.#worldstate.init(); } if (features.includes("twitter")) this.#twitter = new TwitterCache(this); logger.silly("hey look, i started up..."); this.setupLogging(); } /** * Set up internal logging * @private */ setupLogging() { this.on("error", logger.error); this.on("rss", (body) => logger.silly(`emitted: ${body.id}`)); this.on("ws:update:raw", (body) => logger.silly(`emitted raw: ${body.platform}`)); this.on("ws:update:parsed", (body) => logger.silly(`emitted parsed: ${body.platform} in ${body.language}`)); this.on("ws:update:event", (body) => logger.silly(`emitted event: ${body.id} ${body.platform} in ${body.language}`)); this.on("tweet", (body) => logger.silly(`emitted: ${body.id}`)); } /** * Get current rss feed items * @returns RSS feed items */ getRss() { if (!this.#rss) return void 0; return this.#rss.feeder.list.map((i) => ({ url: i.url, items: i.items })); } /** * Get a specific worldstate, defaulting to 'pc' for the platform and 'en' for the language * @param language - locale/language to fetch * @returns Requested worldstate */ getWorldstate(language = "en") { if (!this.#worldstate) return void 0; return this.#worldstate?.get(language); } get debug() { return { rss: FEATURES.includes("rss") ? this.getRss() : void 0, worldstate: FEATURES.includes("worldstate") ? this.#worldstate?.get() : void 0, twitter: this.#twitter?.clientInfoValid ? this.#twitter.getData() : void 0 }; } /** * Get Twitter data * @returns Promised twitter data */ async getTwitter() { return this.#twitter?.clientInfoValid ? this.#twitter.getData() : void 0; } destroy() { if (this.#rss) { this.#rss.destroy(); this.#rss = void 0; } if (this.#worldstate) { this.#worldstate.destroy(); this.#worldstate = void 0; } if (this.#twitter) { this.#twitter.dispose(); this.#twitter = void 0; } } }; //#endregion export { WorldstateEmitter as default };