diff options
-rw-r--r-- | build/cleaners/player.js | 19 | ||||
-rw-r--r-- | build/cleaners/rank.js | 55 | ||||
-rw-r--r-- | build/cleaners/skyblock/minions.js | 63 | ||||
-rw-r--r-- | build/cleaners/skyblock/stats.js | 66 | ||||
-rw-r--r-- | build/cleaners/socialmedia.js | 10 | ||||
-rw-r--r-- | build/hypixel.js | 212 | ||||
-rw-r--r-- | build/hypixelApi.js | 66 | ||||
-rw-r--r-- | build/hypixelCached.js | 227 | ||||
-rw-r--r-- | build/index.js | 18 | ||||
-rw-r--r-- | build/mojang.js | 40 | ||||
-rw-r--r-- | build/util.js | 78 | ||||
-rw-r--r-- | package.json | 2 |
12 files changed, 855 insertions, 1 deletions
diff --git a/build/cleaners/player.js b/build/cleaners/player.js new file mode 100644 index 0000000..25ae1f2 --- /dev/null +++ b/build/cleaners/player.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.cleanPlayerResponse = void 0; +const hypixel_1 = require("../hypixel"); +const socialmedia_1 = require("./socialmedia"); +const rank_1 = require("./rank"); +const util_1 = require("../util"); +async function cleanPlayerResponse(data) { + // Cleans up a 'player' api response + console.log('cleanPlayerResponse', data.stats.SkyBlock.profiles); + return { + uuid: util_1.undashUuid(data.uuid), + username: data.displayname, + rank: rank_1.parseRank(data), + socials: socialmedia_1.parseSocialMedia(data.socialMedia), + profiles: hypixel_1.cleanPlayerSkyblockProfiles(data.stats.SkyBlock.profiles) + }; +} +exports.cleanPlayerResponse = cleanPlayerResponse; diff --git a/build/cleaners/rank.js b/build/cleaners/rank.js new file mode 100644 index 0000000..875d2db --- /dev/null +++ b/build/cleaners/rank.js @@ -0,0 +1,55 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parseRank = void 0; +const util_1 = require("../util"); +const rankColors = { + 'NONE': '7', + 'VIP': 'a', + 'VIP+': 'a', + 'MVP': 'b', + 'MVP+': 'b', + 'MVP++': '6', + 'YOUTUBE': 'c', + 'HELPER': '9', + 'MODERATOR': '2', + 'ADMIN': 'c' +}; +/** Response cleaning (reformatting to be nicer) */ +function parseRank({ packageRank, newPackageRank, monthlyPackageRank, rankPlusColor, rank, prefix }) { + let name; + let color; + let colored; + if (prefix) { // derive values from prefix + colored = prefix; + color = util_1.minecraftColorCodes[colored.match(/§./)[0][1]]; + name = colored.replace(/§./g, '').replace(/[\[\]]/g, ''); + } + else { + name = rank + || newPackageRank.replace('_PLUS', '+') + || packageRank.replace('_PLUS', '+') + || monthlyPackageRank; + // MVP++ is called Superstar for some reason + if (name === 'SUPERSTAR') + name = 'MVP++'; + // YouTube rank is called YouTuber, change this to the proper name + else if (name === 'YOUTUBER') + name = 'YOUTUBE'; + const plusColor = util_1.colorCodeFromName(rankPlusColor); + color = util_1.minecraftColorCodes[rankColors[name]]; + const rankColorPrefix = rankColors[name] ? '§' + rankColors[name] : ''; + const nameWithoutPlus = name.split('+')[0]; + const plusesInName = '+'.repeat(name.split('+').length - 1); + console.log(plusColor, nameWithoutPlus, plusesInName); + if (plusColor && plusesInName.length >= 1) + colored = `${rankColorPrefix}[${nameWithoutPlus}§${plusColor}${plusesInName}${rankColorPrefix}]`; + else + colored = `${rankColorPrefix}[${name}]`; + } + return { + name, + color, + colored + }; +} +exports.parseRank = parseRank; diff --git a/build/cleaners/skyblock/minions.js b/build/cleaners/skyblock/minions.js new file mode 100644 index 0000000..8ffa9c1 --- /dev/null +++ b/build/cleaners/skyblock/minions.js @@ -0,0 +1,63 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.combineMinionArrays = exports.cleanMinions = void 0; +const hypixel_1 = require("../../hypixel"); +/** + * Clean the minions provided by Hypixel + * @param minionsRaw The minion data provided by the Hypixel API + */ +function cleanMinions(minionsRaw) { + const minions = []; + for (const minionRaw of minionsRaw ?? []) { + // do some regex magic to get the minion name and level + // examples of potential minion names: CLAY_11, PIG_1, MAGMA_CUBE_4 + const minionName = minionRaw.split(/_\d/)[0].toLowerCase(); + const minionLevel = parseInt(minionRaw.split(/\D*_/)[1]); + let matchingMinion = minions.find(m => m.name === minionName); + if (!matchingMinion) { + // if the minion doesnt already exist in the minions array, then create it + matchingMinion = { + name: minionName, + levels: new Array(hypixel_1.maxMinion).fill(false) + }; + minions.push(matchingMinion); + } + while (minionLevel > matchingMinion.levels.length) + // if hypixel increases the minion level, this will increase with it + matchingMinion.levels.push(false); + // set the minion at that level to true + matchingMinion.levels[minionLevel - 1] = true; + } + return minions; +} +exports.cleanMinions = cleanMinions; +/** + * Combine multiple arrays of minions into one, useful when getting the minions for members + * @param minions An array of arrays of minions + */ +function combineMinionArrays(minions) { + const resultMinions = []; + for (const memberMinions of minions) { + for (const minion of memberMinions) { + // this is a reference, so we can directly modify the attributes for matchingMinionReference + // and they'll be in the resultMinions array + const matchingMinionReference = resultMinions.find(m => m.name === minion.name); + if (!matchingMinionReference) { + // if the minion name isn't already in the array, add it! + resultMinions.push(minion); + } + else { + // This should never happen, but in case the length of `minion.levels` is longer than + // `matchingMinionReference.levels`, then it should be extended to be equal length + while (matchingMinionReference.levels.length < minion.levels.length) + matchingMinionReference.levels.push(null); + for (let i = 0; i < minion.levels.length; i++) { + if (minion.levels[i]) + matchingMinionReference.levels[i] = true; + } + } + } + } + return resultMinions; +} +exports.combineMinionArrays = combineMinionArrays; diff --git a/build/cleaners/skyblock/stats.js b/build/cleaners/skyblock/stats.js new file mode 100644 index 0000000..81f5544 --- /dev/null +++ b/build/cleaners/skyblock/stats.js @@ -0,0 +1,66 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.cleanProfileStats = void 0; +const statCategories = { + 'deaths': ['deaths_', 'deaths'], + 'kills': ['kills_', 'kills'], + 'fishing': ['items_fished_', 'items_fished'], + 'auctions': ['auctions_'], + 'races': ['_best_time'], + 'misc': null // everything else goes here +}; +function categorizeStat(statNameRaw) { + // 'deaths_void' + for (const statCategory in statCategories) { + // 'deaths' + const statCategoryMatchers = statCategories[statCategory]; + if (statCategoryMatchers == null) { + // If it's null, just go with this. Can only ever be 'misc' + return { + category: statCategory, + name: statNameRaw + }; + } + for (const categoryMatch of statCategoryMatchers) { + // ['deaths_'] + let trailingEnd = categoryMatch[0] == '_'; + let trailingStart = categoryMatch.substr(-1) == '_'; + if (trailingStart && statNameRaw.startsWith(categoryMatch)) { + return { + category: statCategory, + name: statNameRaw.substr(categoryMatch.length) + }; + } + else if (trailingEnd && statNameRaw.endsWith(categoryMatch)) { + return { + category: statCategory, + name: statNameRaw.substr(0, categoryMatch.length) + }; + } + else if (statNameRaw == categoryMatch) { + // if it matches exactly, we don't know the name. will be defaulted to category later on + return { + category: statCategory, + name: null + }; + } + } + } + // this should never happen, as it'll default to misc and return if nothing is found + return { + category: null, + name: statNameRaw + }; +} +function cleanProfileStats(statsRaw) { + // TODO: add type for statsRaw (probably in hypixelApi.ts since its coming from there) + const stats = {}; + for (let statNameRaw in statsRaw) { + let { category: statCategory, name: statName } = categorizeStat(statNameRaw); + if (!stats[statCategory]) + stats[statCategory] = {}; + stats[statCategory][statName || 'total'] = statsRaw[statNameRaw]; + } + return stats; +} +exports.cleanProfileStats = cleanProfileStats; diff --git a/build/cleaners/socialmedia.js b/build/cleaners/socialmedia.js new file mode 100644 index 0000000..fb0bcfd --- /dev/null +++ b/build/cleaners/socialmedia.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parseSocialMedia = void 0; +function parseSocialMedia(socialMedia) { + return { + discord: socialMedia?.links?.DISCORD || null, + forums: socialMedia?.links?.HYPIXEL || null + }; +} +exports.parseSocialMedia = parseSocialMedia; diff --git a/build/hypixel.js b/build/hypixel.js new file mode 100644 index 0000000..0ad63f8 --- /dev/null +++ b/build/hypixel.js @@ -0,0 +1,212 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.fetchMemberProfile = exports.fetchUser = exports.cleanPlayerSkyblockProfiles = exports.sendCleanApiRequest = exports.maxMinion = exports.saveInterval = void 0; +const minions_1 = require("./cleaners/skyblock/minions"); +const stats_1 = require("./cleaners/skyblock/stats"); +const player_1 = require("./cleaners/player"); +const hypixelApi_1 = require("./hypixelApi"); +const cached = __importStar(require("./hypixelCached")); +// the interval at which the "last_save" parameter updates in the hypixel api, this is 3 minutes +exports.saveInterval = 60 * 3 * 1000; +// the highest level a minion can be +exports.maxMinion = 11; +/** + * Send a request to api.hypixel.net using a random key, clean it up to be more useable, and return it + */ +async function sendCleanApiRequest({ path, args }, included, cleaned = true) { + const key = await hypixelApi_1.chooseApiKey(); + const rawResponse = await hypixelApi_1.sendApiRequest({ path, key, args }); + if (rawResponse.throttled) { + // if it's throttled, wait a second and try again + console.log('throttled :/'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return await sendCleanApiRequest({ path, args }, included, cleaned); + } + if (cleaned) { + // if it needs to clean the response, call cleanResponse + return await cleanResponse({ path, data: rawResponse }, included = included); + } + else { + // this is provided in case the caller wants to do the cleaning itself + // used in skyblock/profile, as cleaning the entire profile would use too much cpu + return rawResponse; + } +} +exports.sendCleanApiRequest = sendCleanApiRequest; +async function cleanSkyBlockProfileMemberResponse(member, included = null) { + // Cleans up a member (from skyblock/profile) + // profiles.members[] + const statsIncluded = included == null || included.includes('stats'); + return { + uuid: member.uuid, + username: await cached.usernameFromUser(member.uuid), + last_save: member.last_save, + first_join: member.first_join, + // last_death: ??? idk how this is formatted, + stats: statsIncluded ? stats_1.cleanProfileStats(member.stats) : undefined, + minions: statsIncluded ? minions_1.cleanMinions(member.crafted_generators) : undefined, + }; +} +/** Return a `CleanProfile` instead of a `CleanFullProfile`, useful when we need to get members but don't want to waste much ram */ +async function cleanSkyblockProfileResponseLighter(data) { + // We use Promise.all so it can fetch all the usernames at once instead of waiting for the previous promise to complete + const promises = []; + for (const memberUUID in data.members) { + const memberRaw = data.members[memberUUID]; + memberRaw.uuid = memberUUID; + // we pass an empty array to make it not check stats + promises.push(cleanSkyBlockProfileMemberResponse(memberRaw, [])); + } + const cleanedMembers = await Promise.all(promises); + return { + uuid: data.profile_id, + name: data.cute_name, + members: cleanedMembers, + }; +} +/** This function is very costly and shouldn't be called often. Use cleanSkyblockProfileResponseLighter if you don't need all the data */ +async function cleanSkyblockProfileResponse(data) { + const cleanedMembers = []; + for (const memberUUID in data.members) { + const memberRaw = data.members[memberUUID]; + memberRaw.uuid = memberUUID; + const member = await cleanSkyBlockProfileMemberResponse(memberRaw, ['stats']); + cleanedMembers.push(member); + } + const memberMinions = []; + for (const member of cleanedMembers) { + memberMinions.push(member.minions); + } + const minions = minions_1.combineMinionArrays(memberMinions); + // return more detailed info + return { + uuid: data.profile_id, + name: data.cute_name, + members: cleanedMembers, + bank: { + balance: data?.banking?.balance ?? 0, + // TODO: make transactions good + history: data?.banking?.transactions ?? [] + }, + minions + }; +} +function cleanPlayerSkyblockProfiles(rawProfiles) { + let profiles = []; + for (const profile of Object.values(rawProfiles)) { + profiles.push({ + uuid: profile.profile_id, + name: profile.cute_name + }); + } + console.log('cleanPlayerSkyblockProfiles', profiles); + return profiles; +} +exports.cleanPlayerSkyblockProfiles = cleanPlayerSkyblockProfiles; +/** Convert an array of raw profiles into clean profiles */ +async function cleanSkyblockProfilesResponse(data) { + const cleanedProfiles = []; + for (const profile of data) { + let cleanedProfile = await cleanSkyblockProfileResponseLighter(profile); + cleanedProfiles.push(cleanedProfile); + } + return cleanedProfiles; +} +async function cleanResponse({ path, data }, included) { + // Cleans up an api response + switch (path) { + case 'player': return await player_1.cleanPlayerResponse(data.player); + case 'skyblock/profile': return await cleanSkyblockProfileResponse(data.profile); + case 'skyblock/profiles': return await cleanSkyblockProfilesResponse(data.profiles); + } +} +/** + * Higher level function that requests the api for a user, and returns the cleaned response + * This is safe to fetch many times because the results are cached! + * @param included lets you choose what is returned, so there's less processing required on the backend + * used inclusions: player, profiles + */ +async function fetchUser({ user, uuid, username }, included = ['player']) { + if (!uuid) { + // If the uuid isn't provided, get it + uuid = await cached.uuidFromUser(user || username); + } + const includePlayers = included.includes('player'); + const includeProfiles = included.includes('profiles'); + let profilesData; + let basicProfilesData; + let playerData; + if (includePlayers) { + playerData = await cached.fetchPlayer(uuid); + // if not including profiles, include lightweight profiles just in case + if (!includeProfiles) + basicProfilesData = playerData.profiles; + playerData.profiles = undefined; + } + if (includeProfiles) { + profilesData = await cached.fetchSkyblockProfiles(uuid); + } + let activeProfile = null; + let lastOnline = 0; + if (includeProfiles) { + for (const profile of profilesData) { + const member = profile.members.find(member => member.uuid === uuid); + if (member.last_save > lastOnline) { + lastOnline = member.last_save; + activeProfile = profile; + } + } + } + return { + player: playerData ?? null, + profiles: profilesData ?? basicProfilesData, + activeProfile: includeProfiles ? activeProfile?.uuid : undefined, + online: includeProfiles ? lastOnline > (Date.now() - exports.saveInterval) : undefined + }; +} +exports.fetchUser = fetchUser; +/** + * Fetch a CleanMemberProfile from a user and string + * This is safe to use many times as the results are cached! + * @param user A username or uuid + * @param profile A profile name or profile uuid + */ +async function fetchMemberProfile(user, profile) { + const playerUuid = await cached.uuidFromUser(user); + const profileUuid = await cached.fetchProfileUuid(user, profile); + const player = await cached.fetchPlayer(playerUuid); + const cleanProfile = await cached.fetchProfile(playerUuid, profileUuid); + const member = cleanProfile.members.find(m => m.uuid === playerUuid); + return { + member: { + profileName: cleanProfile.name, + first_join: member.first_join, + last_save: member.last_save, + // add all other data relating to the hypixel player, such as username, rank, etc + ...player + }, + profile: { + minions: cleanProfile.minions + } + }; +} +exports.fetchMemberProfile = fetchMemberProfile; diff --git a/build/hypixelApi.js b/build/hypixelApi.js new file mode 100644 index 0000000..5cbd829 --- /dev/null +++ b/build/hypixelApi.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.sendApiRequest = exports.chooseApiKey = void 0; +const node_fetch_1 = __importDefault(require("node-fetch")); +const util_1 = require("./util"); +const https_1 = require("https"); +require('dotenv').config(); +// We need to create an agent to prevent memory leaks and to only do dns lookups once +const httpsAgent = new https_1.Agent({ + keepAlive: true +}); +/* Lower level code related to the Hypixel api */ +const apiKeys = process.env.keys.split(' '); +const apiKeyUsage = {}; +const baseHypixelAPI = 'https://api.hypixel.net'; +/** Choose the best current API key */ +function chooseApiKey() { + // find the api key with the lowest amount of uses + let bestKeyUsage = null; + let bestKey = null; + for (var key of util_1.shuffle(apiKeys)) { + const keyUsage = apiKeyUsage[key]; + // if the key has never been used before, use it + if (!keyUsage) + return key; + // if the key has reset since the last use, set the remaining count to the default + if (Date.now() > keyUsage.reset) + keyUsage.remaining = keyUsage.limit; + // if this key has more uses remaining than the current known best one, save it + if (!bestKeyUsage || keyUsage.remaining > bestKeyUsage.remaining) { + bestKeyUsage = keyUsage; + bestKey = key; + } + } + return bestKey; +} +exports.chooseApiKey = chooseApiKey; +/** Send an HTTP request to the Hypixel API */ +async function sendApiRequest({ path, key, args }) { + console.log('sending api request to', path, args); + // Send a raw http request to api.hypixel.net, and return the parsed json + if (key) + // If there's an api key, add it to the arguments + args.key = key; + // Construct a url from the base api url, path, and arguments + const fetchUrl = baseHypixelAPI + '/' + path + '?' + util_1.jsonToQuery(args); + const fetchResponse = await node_fetch_1.default(fetchUrl, { agent: () => httpsAgent }); + if (fetchResponse.headers['ratelimit-limit']) + // remember how many uses it has + apiKeyUsage[key] = { + remaining: fetchResponse.headers['ratelimit-remaining'], + limit: fetchResponse.headers['ratelimit-limit'], + reset: Date.now() + parseInt(fetchResponse.headers['ratelimit-reset']) * 1000 + }; + const fetchJsonParsed = await fetchResponse.json(); + if (fetchJsonParsed.throttle) { + apiKeyUsage[key].remaining = 0; + console.log('throttled :('); + return { throttled: true }; + } + return fetchJsonParsed; +} +exports.sendApiRequest = sendApiRequest; diff --git a/build/hypixelCached.js b/build/hypixelCached.js new file mode 100644 index 0000000..c874089 --- /dev/null +++ b/build/hypixelCached.js @@ -0,0 +1,227 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.fetchProfileName = exports.fetchProfile = exports.fetchProfileUuid = exports.fetchSkyblockProfiles = exports.fetchPlayer = exports.usernameFromUser = exports.uuidFromUser = void 0; +const node_cache_1 = __importDefault(require("node-cache")); +const mojang = __importStar(require("./mojang")); +const hypixel = __importStar(require("./hypixel")); +const util_1 = require("./util"); +/** +Hypixel... but with caching +All the caching in this project is done here! +*/ +// cache usernames for 4 hours +const usernameCache = new node_cache_1.default({ + stdTTL: 60 * 60 * 4, + checkperiod: 60, + useClones: false, +}); +const basicProfilesCache = new node_cache_1.default({ + stdTTL: 60 * 10, + checkperiod: 60, + useClones: false, +}); +const playerCache = new node_cache_1.default({ + stdTTL: 60, + checkperiod: 10, + useClones: false, +}); +const profileCache = new node_cache_1.default({ + stdTTL: 30, + checkperiod: 10, + useClones: false, +}); +const profilesCache = new node_cache_1.default({ + stdTTL: 60 * 3, + checkperiod: 10, + useClones: false, +}); +const profileNameCache = new node_cache_1.default({ + stdTTL: 60 * 60, + checkperiod: 60, + useClones: false, +}); +/** + * Fetch the uuid from a user + * @param user A user can be either a uuid or a username + */ +async function uuidFromUser(user) { + if (usernameCache.has(util_1.undashUuid(user))) + // check if the uuid is a key + return util_1.undashUuid(user); + // check if the username is a value + const uuidToUsername = usernameCache.mget(usernameCache.keys()); + for (const [uuid, username] of Object.entries(uuidToUsername)) { + if (user.toLowerCase() === username.toLowerCase()) + return uuid; + } + // not cached, actually fetch mojang api now + let { uuid, username } = await mojang.mojangDataFromUser(user); + // remove dashes from the uuid so its more normal + uuid = util_1.undashUuid(uuid); + usernameCache.set(uuid, username); + return uuid; +} +exports.uuidFromUser = uuidFromUser; +/** + * Fetch the username from a user + * @param user A user can be either a uuid or a username + */ +async function usernameFromUser(user) { + if (usernameCache.has(util_1.undashUuid(user))) { + return usernameCache.get(util_1.undashUuid(user)); + } + let { uuid, username } = await mojang.mojangDataFromUser(user); + uuid = util_1.undashUuid(uuid); + usernameCache.set(uuid, username); + return username; +} +exports.usernameFromUser = usernameFromUser; +async function fetchPlayer(user) { + const playerUuid = await uuidFromUser(user); + if (playerCache.has(playerUuid)) { + console.log('cache hit! fetchPlayer', playerUuid); + return playerCache.get(playerUuid); + } + const cleanPlayer = await hypixel.sendCleanApiRequest({ + path: 'player', + args: { uuid: playerUuid } + }); + playerCache.set(playerUuid, cleanPlayer); + return cleanPlayer; +} +exports.fetchPlayer = fetchPlayer; +async function fetchSkyblockProfiles(playerUuid) { + if (profilesCache.has(playerUuid)) { + console.log('cache hit! fetchSkyblockProfiles', playerUuid); + return profilesCache.get(playerUuid); + } + const profiles = await hypixel.sendCleanApiRequest({ + path: 'skyblock/profiles', + args: { + uuid: playerUuid + } + }); + const basicProfiles = []; + // create the basicProfiles array + for (const profile of profiles) { + const basicProfile = { + name: profile.name, + uuid: profile.uuid, + members: profile.members.map(m => { + return { + uuid: m.uuid, + username: m.username, + first_join: m.first_join, + last_save: m.last_save + }; + }) + }; + basicProfiles.push(basicProfile); + } + // cache the profiles + profilesCache.set(playerUuid, basicProfiles); + return basicProfiles; +} +exports.fetchSkyblockProfiles = fetchSkyblockProfiles; +/** Fetch an array of `BasicProfile`s */ +async function fetchBasicProfiles(user) { + const playerUuid = await uuidFromUser(user); + if (basicProfilesCache.has(playerUuid)) { + console.log('cache hit! fetchBasicProfiles'); + return basicProfilesCache.get(playerUuid); + } + const player = await fetchPlayer(playerUuid); + const profiles = player.profiles; + basicProfilesCache.set(playerUuid, profiles); + console.log(player); + // cache the profile names and uuids to profileNameCache because we can + for (const profile of profiles) + profileNameCache.set(`${playerUuid}.${profile.uuid}`, profile.name); + return profiles; +} +/** + * Fetch a profile UUID from its name and user + * @param user A username or uuid + * @param profile A profile name or profile uuid + */ +async function fetchProfileUuid(user, profile) { + const profiles = await fetchBasicProfiles(user); + const profileUuid = util_1.undashUuid(profile); + for (const p of profiles) { + if (p.name.toLowerCase() === profileUuid.toLowerCase()) + return util_1.undashUuid(p.uuid); + else if (util_1.undashUuid(p.uuid) === util_1.undashUuid(profileUuid)) + return util_1.undashUuid(p.uuid); + } +} +exports.fetchProfileUuid = fetchProfileUuid; +/** + * Fetch an entire profile from the user and profile data + * @param user A username or uuid + * @param profile A profile name or profile uuid + */ +async function fetchProfile(user, profile) { + const profileUuid = await fetchProfileUuid(user, profile); + if (profileCache.has(profileUuid)) { + console.log('cache hit! fetchProfile'); + // we have the profile cached, return it :) + return profileCache.get(profileUuid); + } + const profileName = await fetchProfileName(user, profile); + const cleanProfile = await hypixel.sendCleanApiRequest({ + path: 'skyblock/profile', + args: { + profile: profileUuid + } + }); + // we know the name from fetchProfileName, so set it here + cleanProfile.name = profileName; + profileCache.set(profileUuid, cleanProfile); + return cleanProfile; +} +exports.fetchProfile = fetchProfile; +/** + * Fetch the name of a profile from the user and profile uuid + * @param user A player uuid or username + * @param profile A profile uuid or name + */ +async function fetchProfileName(user, profile) { + // we're fetching the profile and player uuid again in case we were given a name, but it's cached so it's not much of a problem + const profileUuid = await fetchProfileUuid(user, profile); + const playerUuid = await uuidFromUser(user); + if (profileNameCache.has(`${playerUuid}.${profileUuid}`)) { + // Return the profile name if it's cached + console.log('cache hit! fetchProfileName'); + return profileNameCache.get(`${playerUuid}.${profileUuid}`); + } + const basicProfiles = await fetchBasicProfiles(playerUuid); + let profileName; + for (const basicProfile of basicProfiles) + if (basicProfile.uuid === playerUuid) + profileName = basicProfile.name; + profileNameCache.set(`${playerUuid}.${profileUuid}`, profileName); + return profileName; +} +exports.fetchProfileName = fetchProfileName; diff --git a/build/index.js b/build/index.js new file mode 100644 index 0000000..bfc8b8d --- /dev/null +++ b/build/index.js @@ -0,0 +1,18 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const hypixel_1 = require("./hypixel"); +const app = express_1.default(); +app.get('/', async (req, res) => { + res.json({ ok: true }); +}); +app.get('/player/:user', async (req, res) => { + res.json(await hypixel_1.fetchUser({ user: req.params.user }, ['profiles', 'player'])); +}); +app.get('/player/:user/:profile', async (req, res) => { + res.json(await hypixel_1.fetchMemberProfile(req.params.user, req.params.profile)); +}); +app.listen(8080, () => console.log('App started :)')); diff --git a/build/mojang.js b/build/mojang.js new file mode 100644 index 0000000..981bf31 --- /dev/null +++ b/build/mojang.js @@ -0,0 +1,40 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.usernameFromUser = exports.uuidFromUser = exports.mojangDataFromUser = void 0; +const node_fetch_1 = __importDefault(require("node-fetch")); +const https_1 = require("https"); +// We need to create an agent to prevent memory leaks +const httpsAgent = new https_1.Agent({ + keepAlive: true +}); +/** + * Get mojang api data from ashcon.app + */ +async function mojangDataFromUser(user) { + console.log('cache miss :( mojangDataFromUser', user); + const fetchResponse = await node_fetch_1.default('https://api.ashcon.app/mojang/v2/user/' + user, { agent: () => httpsAgent }); + return await fetchResponse.json(); +} +exports.mojangDataFromUser = mojangDataFromUser; +/** + * Fetch the uuid from a user + * @param user A user can be either a uuid or a username + */ +async function uuidFromUser(user) { + const fetchJSON = await mojangDataFromUser(user); + return fetchJSON.uuid.replace(/-/g, ''); +} +exports.uuidFromUser = uuidFromUser; +/** + * Fetch the username from a user + * @param user A user can be either a uuid or a username + */ +async function usernameFromUser(user) { + // get a minecraft uuid from a username, using ashcon.app's mojang api + const fetchJSON = await mojangDataFromUser(user); + return fetchJSON.username; +} +exports.usernameFromUser = usernameFromUser; diff --git a/build/util.js b/build/util.js new file mode 100644 index 0000000..4c7fe64 --- /dev/null +++ b/build/util.js @@ -0,0 +1,78 @@ +"use strict"; +/* Utility functions (not related to Hypixel) */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.colorCodeFromName = exports.minecraftColorCodes = exports.shuffle = exports.jsonToQuery = exports.queryToJson = exports.undashUuid = void 0; +function undashUuid(uuid) { + return uuid.replace(/-/g, ''); +} +exports.undashUuid = undashUuid; +function queryToJson(queryString) { + var query = {}; + var pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&'); + for (var i = 0; i < pairs.length; i++) { + var pair = pairs[i].split('='); + query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); + } + return query; +} +exports.queryToJson = queryToJson; +function jsonToQuery(data) { + return Object.entries(data || {}).map(e => e.join('=')).join('&'); +} +exports.jsonToQuery = jsonToQuery; +function shuffle(a) { + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} +exports.shuffle = shuffle; +exports.minecraftColorCodes = { + '0': '#000000', + '1': '#0000be', + '2': '#00be00', + '3': '#00bebe', + '4': '#be0000', + '5': '#be00be', + '6': '#ffaa00', + '7': '#bebebe', + '8': '#3f3f3f', + '9': '#3f3ffe', + 'a': '#3ffe3f', + 'b': '#3ffefe', + 'c': '#fe3f3f', + 'd': '#fe3ffe', + 'e': '#fefe3f', + 'f': '#ffffff', + 'black': '#000000', + 'dark_blue': '#0000be', + 'dark_green': '#00be00', + 'dark_aqua': '#00bebe', + 'dark_red': '#be0000', + 'dark_purple': '#be00be', + 'gold': '#ffaa00', + 'gray': '#bebebe', + 'dark_gray': '#3f3f3f', + 'blue': '#3f3ffe', + 'green': '#3ffe3f', + 'aqua': '#3ffefe', + 'red': '#fe3f3f', + 'light_purple': '#fe3ffe', + 'yellow': '#fefe3f', + 'white': '#ffffff', +}; +/** + * Converts a color name to the code + * For example: blue -> 9 + * @param colorName The name of the color (blue, red, aqua, etc) + */ +function colorCodeFromName(colorName) { + const hexColor = exports.minecraftColorCodes[colorName.toLowerCase()]; + for (const key in exports.minecraftColorCodes) { + const value = exports.minecraftColorCodes[key]; + if (key.length === 1 && value === hexColor) + return key; + } +} +exports.colorCodeFromName = colorCodeFromName; diff --git a/package.json b/package.json index 4b544ef..c08b643 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "compile": "" + "compile": "tsc" }, "repository": { "type": "git", |