worldstate-emitter
Version:
Event emitter for Warframe worldstate & other events - TypeScript support included
1,282 lines (1,262 loc) • 37.4 kB
JavaScript
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 };