From 1bfda2a5449704ea69e9ac70e574047ce3320d35 Mon Sep 17 00:00:00 2001 From: Giorgio <20621396+giorgi-o@users.noreply.github.com> Date: Thu, 3 Nov 2022 22:07:54 +0000 Subject: [PATCH 1/3] use preload trick to work with new electron version --- CPP_preload.js | 332 ++ CrossPlatformPlaying.plugin.js | 9190 ++++++++++++++++---------------- add_this_to_index.js | 23 + 3 files changed, 4837 insertions(+), 4708 deletions(-) create mode 100644 CPP_preload.js create mode 100644 add_this_to_index.js diff --git a/CPP_preload.js b/CPP_preload.js new file mode 100644 index 0000000..0f73a24 --- /dev/null +++ b/CPP_preload.js @@ -0,0 +1,332 @@ +// electron logic +const electron = require("electron"); +const _preload = electron.ipcRenderer.sendSync("_original_preload"); +if(typeof _preload == "string") + require(_preload); + +// CrossPlatformPlaying logic +const net = require("net"); +const tls = require("tls"); +const https = require("https"); +const crypto = require("crypto"); + +// custom websocket client adding support for HTTP headers and cookies +class SimpleSocket extends EventTarget { + constructor(url, options={}) { + super(); + this.on = this.addEventListener; + this.emit = (e, d) => this.dispatchEvent(new CustomEvent(e, d)); + + this.url = new URL(url); + if(this.url.protocol !== "wss:") return console.error("Only wss WebSockets are supported!"); + + this.key = options.key || crypto.randomBytes(16).toString('base64'); + + this.status = SimpleSocket.states.CONNECTING; + + const reqOptions = { + hostname: this.url.hostname, + host: this.url.host, + port: this.url.port || 443, + path: this.url.pathname + this.url.search, + rejectUnauthorized: false, + headers: { + "Connection": "Upgrade", + "Upgrade": "websocket", + "Sec-WebSocket-Key": this.key, + "Sec-WebSocket-Version": options.version || 13, + ...options.headers + }, + ...options.requestOptions + } + if(options.protocol) reqOptions.headers["Sec-WebSocket-Protocol"] = options.protocol; + if(options.extensions) reqOptions.headers["Sec-WebSocket-Extensions"] = options.extensions; + + this.req = https.request(reqOptions); + + this.req.on('upgrade', (res, socket, head) => { + try { // check accept header + const expected = crypto.createHash('sha1').update(this.key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest('base64'); + if(res.headers["sec-websocket-accept"] !== expected) return console.error("Something fishy is going on... sec-websocket-accept expected vs recieved:", expected, res.headers["sec-websocket-accept"]); + + this.socket = socket; + + socket.on('data', data => this.receive(data)); + socket.on('close', () => this.closed()); + socket.on('error', console.error); + + this.status = SimpleSocket.states.READY; + + if(this.onconnect) this.onconnect(socket, res); + + if(head && head.length) this.receive(head); + } catch(e) { + err(e); + } + }); + + this.req.on("error", console.error); + + this.req.end(); + } + + send(data, opcode) { + try { + if(!this.socket) return; + if(this.status !== SimpleSocket.states.READY) return console.error("Tried to send data on a closed WebSocket!"); + + let header; + if(Buffer.isBuffer(data)) header = 0x82; + else { + header = 0x81; + data = Buffer.from(new TextEncoder().encode(data.toString())); + } + if(opcode) header = 0x80 + opcode; + + let lengthByte = 0x80; // mask set to 1 + let extendedLength; + if(data.length > 65535) { + lengthByte += 127; + (extendedLength = Buffer.alloc(8)).writeBigUInt64BE(BigInt(data.length)); + } else if(data.length > 125) { + lengthByte += 126; + (extendedLength = Buffer.alloc(2)).writeUInt16BE(data.length); + } else { + lengthByte += data.length; + extendedLength = Buffer.alloc(0); + } + + const maskKey = crypto.randomBytes(4); + const maskedData = this.maskData(data, maskKey); + + const buf = Buffer.concat([ + Buffer.from([header, lengthByte]), + extendedLength, maskKey, maskedData + ]); + + this.socket.write(buf); + } catch(e) { + err(e); + } + } + + receive(buf) { + try { + if(!this.onmessage) return; + + const fin = buf[0] >> 7; // bit 0 is FIN + const opcode = buf[0] & 0x0F; // bits 4-7 is opcode + const mask = buf[1] >> 7; // bit 8 is mask + + if(opcode === SimpleSocket.opcode.PING) { + return this.pong(); + } + + let length = buf[1] & 0x7F; + let lengthEnd = 2; + + if(length === 126) { + length = buf.readUInt16BE(2); + lengthEnd = 4; + } else if(length === 127) { + length = buf.readBigUInt64BE(2); + lengthEnd = 10; + } + + let data; + if(mask) { + const key = buf.readUInt32BE(lengthEnd); + lengthEnd += 4; + const masked = buf.subarray(lengthEnd, lengthEnd + length); + + data = this.maskData(masked, key); + } else { + data = buf.subarray(lengthEnd, lengthEnd + length); + } + + if(opcode === SimpleSocket.opcode.CLOSE) { + return this.closed(data); + } else if(this.status !== SimpleSocket.states.READY) { + return console.error("Data received on closed WebSocket!"); + } + + if(opcode === SimpleSocket.opcode.TEXT) data = data.toString('utf8'); + + this.onmessage(data, buf); + } catch(e) { + err(e); + } + } + + close(statusCode, reason) { + if(!this.socket) return; + + let data = []; + if(statusCode) { + data = new Buffer(2); + data.writeUInt16BE(statusCode); + + if(reason) data = Buffer.concat([data, new TextEncoder().encode(reason.toString())]); + } + + this.send(data, SimpleSocket.opcode.CLOSE); + this.status = SimpleSocket.states.CLOSING; + this.socket.end(); + } + + closed(data) { + try { + if(this.status === SimpleSocket.states.CLOSED) return; + + this.status = SimpleSocket.states.CLOSED; + this.socket.destroy(); + + if(!this.onclose) return; + if(data) { + const statusCode = data.readUInt16BE(); + const reason = data.toString('utf-8', 2); + this.onclose(statusCode, reason); + } else this.onclose(); + } catch(e) { + err(e); + } + } + + maskData(data, key) { + const masked = []; + for(let i = 0; i < data.length; i++) + masked.push(data[i] ^ key[i % 4]); + return Buffer.from(masked); + } + + ping() { + this.send([], SimpleSocket.opcode.PING); + } + + pong() { + this.send([], SimpleSocket.opcode.PONG); + } + + static opcode = { + CONT: 0, + TEXT: 1, BIN: 2, + CLOSE: 8, + PING: 9, PONG: 10 + } + + static states = { + CONNECTING: 0, + READY: 1, + CLOSING: 2, + CLOSED: 3 + } +} + +// SimpleSocket todo: +// list of websockets to destroy +// support continuation frames +// support close codes +// support http status codes other than 101 + +const sockets = {}; let socket_index = 0; + +// const closeSocket = (index, {destroy, destroyTimeout}); + +const CPP_preload = { + + https: { + fetch: (url, options={}) => { + return new Promise((resolve) => { + const req = https.request(url, { + method: options.method || "GET", + headers: options.headers || {} + }, resp => { + const res = { + statusCode: resp.statusCode, + headers: resp.headers + }; + let chunks = []; + resp.on('data', (chunk) => chunks.push(chunk)); + resp.on('end', () => { + res.body = Buffer.concat(chunks).toString(options.encoding || "utf8"); + resolve(res); + }); + }) + req.write(options.body || ""); + req.end(); + }); + }, + simpleSocket: (url, options, onconnect, onmessage, onclose) => { + const socket = new SimpleSocket(url, options); + socket.onconnect = onconnect; + socket.onmessage = onmessage; + socket.onclose = onclose; + + const index = socket_index++; + sockets[index] = socket; + return index; + }, + sendToSocket: (index, data) => { + if(!sockets[index]) throw Error("SimpleSocket not found"); + sockets[index].send(data); + }, + closeSocket: (index, statusCode, reason) => { + if(!sockets[index]) throw Error("SimpleSocket not found"); + sockets[index].close(statusCode, reason); + delete sockets[index]; + } + }, + + net: { + createSocket: (host, port, onconnect, ondata, onerror, onclose) => { + const socket = net.createConnection(port, host, onconnect); + + if(ondata) socket.on("data", ondata); + if(onerror) socket.on("error", onerror); + if(onclose) socket.on("close", onclose); + + const index = socket_index++; + sockets[index] = socket; + return index; + }, + sendToSocket: (index, data) => { + if(!sockets[index]) throw Error("Net Socket not found"); + sockets[index].write(data); + }, + closeSocket: (index, destroy=false) => { + if(!sockets[index]) throw Error("Net Socket not found"); + if(destroy) sockets[index].destroy(); + else sockets[index].end(); + delete sockets[index]; + } + }, + + tls: { + createSocket: (host, port, options, onconnect, ondata, onerror, onclose) => { + const socket = tls.connect(port, host, options, onconnect); + + if(ondata) socket.on("data", ondata); + if(onerror) socket.on("error", onerror); + if(onclose) socket.on("close", onclose); + + const index = socket_index++; + sockets[index] = socket; + return index; + }, + sendToSocket: (index, data, encoding, callback) => { + if(!sockets[index]) throw Error("TLS Socket not found"); + sockets[index].write(data, encoding, callback); + }, + closeSocket: (index, destroy=false) => { + if(!sockets[index]) throw Error("TLS Socket not found"); + if(destroy) sockets[index].destroy(); + else sockets[index].end(); + delete sockets[index]; + } + } +} + +electron.contextBridge.exposeInMainWorld("CPP_preload", CPP_preload); + + + diff --git a/CrossPlatformPlaying.plugin.js b/CrossPlatformPlaying.plugin.js index e12cd64..1421704 100644 --- a/CrossPlatformPlaying.plugin.js +++ b/CrossPlatformPlaying.plugin.js @@ -36,4711 +36,4485 @@ ** HELPER ** **************/ -const https = require("https") -const tls = require("tls"); -const fs = require("fs"); -const net = require("net"); -const crypto = require("crypto"); - -// send an HTTP request to a URL, bypassing CORS policy -const fetch = (url, options={}) => { - return new Promise((resolve) => { - const req = https.request(url, { - method: options.method || "GET", - headers: options.headers || {} - }, resp => { - const res = { - statusCode: resp.statusCode, - headers: resp.headers - }; - let chunks = []; - resp.on('data', (chunk) => chunks.push(chunk)); - resp.on('end', () => { - res.body = Buffer.concat(chunks).toString(options.encoding || "utf8"); - resolve(res); - }); - }) - req.write(options.body || ""); - req.end(); - }); -} - -// basic error handling -const err = e => { - console.error(e); - for(const errCode of ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ENOENT", "ECONNABORTED"]) { - if(e.code === errCode || e.errno === errCode) return; // steam & hypixel sometimes time out for no reason - } - debugger; - BdApi.alert("Error happened!\n" + e); -} - -const pluginName = "CrossPlatformPlaying"; -const customRpcAppId = "883483733875892264"; - -const config = { - "info": { - "name": pluginName, - "authors": [{ - "name": "Giorgio", - "discord_id": "316978243716775947", - "github_username": "giorgi-o" - }], - "version": "0.2.8", - "description": "Lets you see what your friends are playing even if they turned off game activity", - "github": "https://github.com/giorgi-o/CrossPlatformPlaying", - "github_raw": "https://raw.githubusercontent.com/giorgi-o/CrossPlatformPlaying/main/CrossPlatformPlaying.plugin.js" - }, - "changelog": [ // added: green, improved: blurple, fixed: red, progress: yellow - { - "title": "League of Legends", - "type": "added", - "items": [ - "Added TFT Double Up", - "Added AFK in the lobby detection", - ] - }, - { - "title": "Valorant", - "type": "improved", - "items": [ - "Added Pearl map", - ] - }, - ] -}; - -// the discord id of the current user (once the plugin loads) -let discord_id = 0; - -// update the user's status in the guild member list -// call this when the user changes game (not just the game state) -let updateUser = (id) => {}; - - -// to implement a new platform, create a subclass of Platform -// and override constructor(), start(), serializeData(), deserializeData(), getPresence(), destroy() and getSettings() -class Platform { - // all platforms should call super() with their platformId - constructor(platformId) { - this.platformId = platformId; // used when storing the platform settings - } - - // should be called in constructor if the platform is enabled - start() {} - - // loads the plugin settings and calls deserializeData(). Should be called in constructor before start() - loadData() { - const data = BdApi.loadData(pluginName, this.platformId); - this.deserializeData(data || {}); - this.saveData(); - } - - // save the data from serializeData() on disk - saveData() { - BdApi.saveData(pluginName, this.platformId, this.serializeData()); - } - - // returns a JSON serializable object containing the data to be saved on disk - serializeData() {}; - - // takes the JSON stored on disk and deserializes it to be used by the platform - deserializeData(data) {}; - - // helper method that can be used for simple platforms - // platforms should implement getPresence with only one argument, discord_id - getPresence(discord_id, discordToPlatformId, presenceCache) { - if(!discord_id || !presenceCache || !discordToPlatformId || !discordToPlatformId[discord_id]) return; - - const presences = []; - for (const platform_id of discordToPlatformId[discord_id]) { - if(presenceCache[platform_id]) - presences.push(presenceCache[platform_id]); - } - if(presences.length) return presences; - } - - // helper method to call updateUser() on a user using their user id - // because typically the processPresence() functions only have the platform id, not discord id - updateUser(platformId, discordToPlatformId) { - const discordIds = []; - for(const [discordId, platformIds] of Object.entries(discordToPlatformId)) { - let matches; - if(Array.isArray(platformIds)) matches = platformIds.includes(platformId); - else matches = platformIds === platformId; - - if(matches) discordIds.push(discordId); - } - - for(const discordId of discordIds) - updateUser(discordId); - } - - // called when the plugin is stopped or the platform is disabled - // pluginShutdown is true if the whole plugin is being disabled - destroy(pluginShutdown) {}; - - // helper function to restart the platform, for example to re-authenticate. - restart() { - this.destroy(false); - this.enabled = true; - this.saveData(); - this.start(); - } - - // should return an HTML element containing the settings panel - // takes as argument an object containing a map of discord IDs to and from usernames - getSettings() { - const div = document.createElement("div"); - div.innerText = "No settings panel for " + this.platformId; - return div; - } - - log(s) { - if(!this.debug) return; - if(typeof s === "object") console.log(`[${this.platformId.toUpperCase()}]`, s); - else console.log(`[${this.platformId.toUpperCase()}] ${s}`); - } -} - -const removeFromList = (list, value) => { - const index = list.indexOf(value); - if(index !== -1) list.splice(index, 1); -} - -const timeouts = []; -const intervals = []; - -const setTimeout = (fn, delay) => { - const id = window.setTimeout(() => { - removeFromList(timeouts, id); - try {fn()} - catch(e) {err(e)} - }, delay); - timeouts.push(id); - return id; -} -const setInterval = (fn, delay) => { - const id = window.setInterval(() => { - try {fn()} - catch(e) {err(e)} - }, delay); - intervals.push(id); - return id; -} - - -// custom websocket client adding support for HTTP headers and cookies -class SimpleSocket extends EventTarget { - constructor(url, options={}) { - super(); - this.on = this.addEventListener; - this.emit = (e, d) => this.dispatchEvent(new CustomEvent(e, d)); - - this.url = new URL(url); - if(this.url.protocol !== "wss:") return console.error("Only wss WebSockets are supported!"); - - this.key = options.key || crypto.randomBytes(16).toString('base64'); - - this.status = SimpleSocket.states.CONNECTING; - - const reqOptions = { - hostname: this.url.hostname, - host: this.url.host, - port: this.url.port || 443, - path: this.url.pathname + this.url.search, - rejectUnauthorized: false, - headers: { - "Connection": "Upgrade", - "Upgrade": "websocket", - "Sec-WebSocket-Key": this.key, - "Sec-WebSocket-Version": options.version || 13, - ...options.headers - }, - ...options.requestOptions - } - if(options.protocol) reqOptions.headers["Sec-WebSocket-Protocol"] = options.protocol; - if(options.extensions) reqOptions.headers["Sec-WebSocket-Extensions"] = options.extensions; - - this.req = https.request(reqOptions); - - this.req.on('upgrade', (res, socket, head) => { - try { // check accept header - const expected = crypto.createHash('sha1').update(this.key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest('base64'); - if(res.headers["sec-websocket-accept"] !== expected) return console.error("Something fishy is going on... sec-websocket-accept expected vs recieved:", expected, res.headers["sec-websocket-accept"]); - - this.socket = socket; - - socket.on('data', data => this.receive(data)); - socket.on('close', () => this.closed()); - socket.on('error', console.error); - - this.status = SimpleSocket.states.READY; - - if(this.onconnect) this.onconnect(socket, res); - - if(head && head.length) this.receive(head); - } catch(e) { - err(e); - } - }); - - this.req.on("error", console.error); - - this.req.end(); - } - - send(data, opcode) { - try { - if(!this.socket) return; - if(this.status !== SimpleSocket.states.READY) return console.error("Tried to send data on a closed WebSocket!"); - - let header; - if(Buffer.isBuffer(data)) header = 0x82; - else { - header = 0x81; - data = Buffer.from(new TextEncoder().encode(data.toString())); - } - if(opcode) header = 0x80 + opcode; - - let lengthByte = 0x80; // mask set to 1 - let extendedLength; - if(data.length > 65535) { - lengthByte += 127; - (extendedLength = Buffer.alloc(8)).writeBigUInt64BE(BigInt(data.length)); - } else if(data.length > 125) { - lengthByte += 126; - (extendedLength = Buffer.alloc(2)).writeUInt16BE(data.length); - } else { - lengthByte += data.length; - extendedLength = Buffer.alloc(0); - } - - const maskKey = crypto.randomBytes(4); - const maskedData = this.maskData(data, maskKey); - - const buf = Buffer.concat([ - Buffer.from([header, lengthByte]), - extendedLength, maskKey, maskedData - ]); - - this.socket.write(buf); - } catch(e) { - err(e); - } - } - - receive(buf) { - try { - if(!this.onmessage) return; - - const fin = buf[0] >> 7; // bit 0 is FIN - const opcode = buf[0] & 0x0F; // bits 4-7 is opcode - const mask = buf[1] >> 7; // bit 8 is mask - - if(opcode === SimpleSocket.opcode.PING) { - return this.pong(); - } - - let length = buf[1] & 0x7F; - let lengthEnd = 2; - - if(length === 126) { - length = buf.readUInt16BE(2); - lengthEnd = 4; - } else if(length === 127) { - length = buf.readBigUInt64BE(2); - lengthEnd = 10; - } - - let data; - if(mask) { - const key = buf.readUInt32BE(lengthEnd); - lengthEnd += 4; - const masked = buf.subarray(lengthEnd, lengthEnd + length); - - data = this.maskData(masked, key); - } else { - data = buf.subarray(lengthEnd, lengthEnd + length); - } - - if(opcode === SimpleSocket.opcode.CLOSE) { - return this.closed(data); - } else if(this.status !== SimpleSocket.states.READY) { - return console.error("Data received on closed WebSocket!"); - } - - if(opcode === SimpleSocket.opcode.TEXT) data = data.toString('utf8'); - - this.onmessage(data, buf); - } catch(e) { - err(e); - } - } - - close(statusCode, reason) { - if(!this.socket) return; - - let data = []; - if(statusCode) { - data = new Buffer(2); - data.writeUInt16BE(statusCode); - - if(reason) data = Buffer.concat([data, new TextEncoder().encode(reason.toString())]); - } - - this.send(data, SimpleSocket.opcode.CLOSE); - this.status = SimpleSocket.states.CLOSING; - this.socket.end(); - } - - closed(data) { - try { - if(this.status === SimpleSocket.states.CLOSED) return; - - this.status = SimpleSocket.states.CLOSED; - this.socket.destroy(); - - if(!this.onclose) return; - if(data) { - const statusCode = data.readUInt16BE(); - const reason = data.toString('utf-8', 2); - this.onclose(statusCode, reason); - } else this.onclose(); - } catch(e) { - err(e); - } - } - - maskData(data, key) { - const masked = []; - for(let i = 0; i < data.length; i++) - masked.push(data[i] ^ key[i % 4]); - return Buffer.from(masked); - } - - ping() { - this.send([], SimpleSocket.opcode.PING); - } - - pong() { - this.send([], SimpleSocket.opcode.PONG); - } - - static opcode = { - CONT: 0, - TEXT: 1, BIN: 2, - CLOSE: 8, - PING: 9, PONG: 10 - } - - static states = { - CONNECTING: 0, - READY: 1, - CLOSING: 2, - CLOSED: 3 - } -} - -// SimpleSocket todo: -// list of websockets to destroy -// support continuation frames -// support close codes -// support http status codes other than 101 - -const Priorities = { - PLAYING: 7, // user has game open & actively playing - IN_LOBBY: 6, // user has game open and is about to launch (in lobby) - DISCORD_RICH_PRESENCE: 5, // discord presence, with rich presence - IN_LOBBY_AFK: 4, // user has game open but is afk - NONPRIMARY_PLAYING: 3, // user has their game open, but this is not the primary presence (e.g. Steam) - DISCORD_NORMAL: 2, // discord presence, without rich presence - SECONDARY: 1 // secondary activity (e.g. Spotify/Twitch) -} - -// helper functions for building the settings panel -const SettingsBuilder = { - enabledSwitch: (platform) => { - const onChange = (value) => { - const wasEnabled = platform.enabled; - platform.enabled = value; - if(!wasEnabled && value) platform.start(); - if(wasEnabled && !value) platform.destroy(); - platform.saveData(); - } - return new ZeresPluginLibrary.Settings.Switch("Enabled", "Whether this platform is enabled", platform.enabled, onChange); - }, - toggleEnabledSwitch: (enabledSwitch) => { - enabledSwitch.getElement().children[0].children[0].children[1].children[0].children[1].click(); - }, - debugSwitch: (platform) => { - const onChange = (value) => { - platform.debug = value; - if(value) platform.log("Debug enabled!"); - } - return new ZeresPluginLibrary.Settings.Switch("Debug", "Whether to print debug info to the console", platform.debug, onChange); - }, - getTextboxInput: (textbox) => { - return textbox.children[0].children[1].children[0]; - }, - textboxWithButton: (name, note, value, onChange, textboxOptions, buttonText, onClick, timeout=50) => { - if(!name) name = ""; // if name is null, button formatting doesn't work for some reason - const textbox = new ZeresPluginLibrary.Settings.Textbox(name, note, value, onChange, textboxOptions).getElement(); - setTimeout(() => { - const button = document.createElement("button"); - button.innerText = buttonText; - button.onclick = onClick; - - button.classList.add("bd-button"); - button.style.fontSize = "16px"; - button.style.marginLeft = "10px"; - button.style.whiteSpace = "nowrap"; - - const div = textbox.children[0].children[1]; - - div.style.flexDirection = "row"; - - div.append(button); - }, timeout); - return textbox; - }, - settingsPanel: (platform, ...nodes) => { - const panel = new ZeresPluginLibrary.Settings.SettingPanel(() => platform.saveData(), ...nodes); - return panel.getElement(); - }, - createDatalist: (id, values) => { - const datalist = document.createElement("datalist"); - datalist.id = id; - for(const value of values) { - const option = document.createElement("option"); - option.value = value; - datalist.append(option); - } - return datalist; - }, - userMapInterface: (platform, platformDatalist, discordDatalist, platformUserList, discordUserList, usersMap, description, platformHeaderValue, platformIdRegex=/./, discordIdRegex=/^\d{15,}$/) => { - /** userList format: { - * idToName: { - * 1234: "gary" - * }, - * nameToId: { - * "gary": 1234 - * } - * } - */ - - // get a few class names from discord (can't find them in ZLibrary) - if(!SettingsBuilder.inputClassNames) SettingsBuilder.inputClassNames = BdApi.findModuleByProps("input", "inputMini", "inputWrapper"); - if(!SettingsBuilder.descriptionClassNames) SettingsBuilder.descriptionClassNames = BdApi.findModuleByProps('labelBold', 'labelDescriptor', 'labelSelected'); - - const userMapDiv = document.createElement("div"); - userMapDiv.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.container); - if(platformDatalist) userMapDiv.append(platformDatalist); - - if(description) { - const descriptionDiv = document.createElement("div"); - descriptionDiv.className = (SettingsBuilder.descriptionClassNames.description); - descriptionDiv.innerHTML = description; - descriptionDiv.style.marginBottom = "6px"; - userMapDiv.append(descriptionDiv); - } - - const table = document.createElement("table"); - table.style.width = "100%"; - userMapDiv.append(table); - - // top row with + button and labels - const topRow = document.createElement("tr"); - topRow.id = platform.platformId + "-row-top"; - - const addRowButton = document.createElement("button"); - addRowButton.innerText = "+"; - addRowButton.className = "bd-button"; - const addRowButtonColumn = document.createElement("th"); - addRowButtonColumn.append(addRowButton); - topRow.append(addRowButtonColumn); - - const platformColumnTitle = document.createElement("th"); - platformColumnTitle.innerText = platformHeaderValue || "Platform user"; - platformColumnTitle.style.color = "var(--header-primary)"; - topRow.append(platformColumnTitle); - - const discordColumnTitle = document.createElement("th"); - discordColumnTitle.innerText = "Discord user"; - discordColumnTitle.style.color = "var(--header-primary)"; - topRow.append(discordColumnTitle); - - table.append(topRow); - - // handle saving data to json - let saveTimeout; - const saveData = () => { - clearTimeout(saveTimeout); - - // delete all entries in old usersMap - for(const user of Object.keys(usersMap)) delete usersMap[user]; - - for(const row of table.children) { - if(row.id === platform.platformId + "-row-top") continue; - - const [, platformColumn, discordColumn] = row.children; - const platformInput = platformColumn.children[0]; - const discordInput = discordColumn.children[0]; - - let platformValue = platformInput.value; - let discordValue = discordInput.value; - - if(platformUserList && platformUserList.nameToId[platformValue]) { - platformValue = platformUserList.nameToId[platformValue]; - } else if(platformIdRegex && !platformIdRegex.test(platformValue)) { - platformInput.style.color = "red"; - continue; - } - platformInput.style.color = null; - - if(discordUserList && discordUserList.nameToId[discordValue]) { - discordValue = discordUserList.nameToId[discordValue]; - } else if(discordIdRegex && !discordIdRegex.test(discordValue)) { - discordInput.style.color = "red"; - continue; - } - discordInput.style.color = null; - - if(!platformValue || !discordValue) continue; - - if(Array.isArray(usersMap[discordValue])) { - usersMap[discordValue].push(platformValue); - } else { - usersMap[discordValue] = [platformValue]; - } - } - - saveTimeout = setTimeout(() => { - platform.saveData(); - }, 500); - } - - let id = 0; - - const addRow = (platformValue, discordValue, insertAtEnd=false) => { - const row = document.createElement("tr"); - row.style.width = "100%"; - row.id = platform.platformId + "-row-" + id.toString(); - - // X button - const removeButton = document.createElement("button"); - removeButton.className = "bd-button"; - removeButton.innerText = "X"; - removeButton.onclick = () => removeRow(row.id); - - const removeButtonColumn = document.createElement("th"); - removeButtonColumn.append(removeButton); - row.append(removeButtonColumn); - - // platform dropdown - const platformInput = document.createElement("input"); - platformInput.className = SettingsBuilder.inputClassNames.input; - platformInput.style.width = "100%"; - platformInput.oninput = saveData; - if(platformValue) platformInput.value = platformValue; - if(platformDatalist) platformInput.setAttribute("list", platformDatalist.id); - - const platformInputColumn = document.createElement("th"); - platformInputColumn.style.width = "50%"; - platformInputColumn.append(platformInput); - row.append(platformInputColumn); - - // discord dropdown - const discordInput = document.createElement("input"); - discordInput.className = SettingsBuilder.inputClassNames.input; - discordInput.style.width = "100%"; - discordInput.oninput = saveData; - if(discordValue) discordInput.value = discordValue; - if(discordDatalist) discordInput.setAttribute("list", discordDatalist.id); - - const discordInputColumn = document.createElement("th"); - discordInputColumn.style.width = "50%"; - discordInputColumn.append(discordInput); - row.append(discordInputColumn); - - if(insertAtEnd) table.append(row); - else table.insertBefore(row, table.children[1]); - - id++; - } - addRowButton.onclick = () => addRow(); - - const removeRow = (id) => { - table.removeChild(document.getElementById(id)); - if(table.children.length === 1) addRow(); - saveData(); - } - - const userCount = Object.values(usersMap).flat().length; - if(userCount === 0) { - addRow("", ""); - } - else if(userCount === 1) { - const [[discord_id, platform_ids]] = Object.entries(usersMap); - addRow(platform_ids[0], discord_id); - } - else { - for(const [discord_id, platform_ids] of Object.entries(usersMap)) { - for(const platform_id of platform_ids) { - addRow(platformUserList && platformUserList.idToName[platform_id] || platform_id, - discordUserList && discordUserList.idToName[discord_id] || discord_id, true); - } - } - } - - // add divider - const divider = document.createElement("div"); - divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.divider); - divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.dividerDefault); - userMapDiv.append(divider); - - return userMapDiv; - }, - list: (platform, theList, description, header, regex=/./) => { - // get a few class names from discord (can't find them in ZLibrary) - if(!SettingsBuilder.inputClassNames) SettingsBuilder.inputClassNames = BdApi.findModuleByProps("input", "inputMini", "inputWrapper"); - if(!SettingsBuilder.descriptionClassNames) SettingsBuilder.descriptionClassNames = BdApi.findModuleByProps('labelBold', 'labelDescriptor', 'labelSelected'); - - const listDiv = document.createElement("div"); - listDiv.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.container); - - if(description) { - const descriptionDiv = document.createElement("div"); - descriptionDiv.className = (SettingsBuilder.descriptionClassNames.description); - descriptionDiv.innerHTML = description; - descriptionDiv.style.marginBottom = "6px"; - listDiv.append(descriptionDiv); - } - - const table = document.createElement("table"); - table.style.width = "100%"; - listDiv.append(table); - - // top row with + button and labels - const topRow = document.createElement("tr"); - topRow.id = platform.platformId + "-list-row-top"; - - const addRowButton = document.createElement("button"); - addRowButton.innerText = "+"; - addRowButton.className = "bd-button"; - const addRowButtonColumn = document.createElement("th"); - addRowButtonColumn.append(addRowButton); - topRow.append(addRowButtonColumn); - - const inputColumnTitle = document.createElement("th"); - inputColumnTitle.innerText = header || "Value"; - inputColumnTitle.style.color = "var(--header-primary)"; - topRow.append(inputColumnTitle); - - table.append(topRow); - - // handle saving data to json - let saveTimeout; - const saveData = () => { - clearTimeout(saveTimeout); - - // delete all entries in old list - theList.length = 0; - - for(const row of table.children) { - if(row.id === platform.platformId + "-list-row-top") continue; - - const inputElement = row.children[1].children[0]; - const inputValue = inputElement.value; - - if(regex && !regex.test(inputValue)) { - inputElement.style.color = "red"; - continue; - } - inputElement.style.color = null; - - if(!inputValue) continue; - - theList.push(inputValue); - } - - saveTimeout = setTimeout(() => { - platform.saveData(); - }, 500); - } - - let id = 0; - - const addRow = (value, insertAtEnd=false) => { - const row = document.createElement("tr"); - row.style.width = "100%"; - row.id = platform.platformId + "-list-row-" + id.toString(); - - // X button - const removeButton = document.createElement("button"); - removeButton.className = "bd-button"; - removeButton.innerText = "X"; - removeButton.onclick = () => removeRow(row.id); - - const removeButtonColumn = document.createElement("th"); - removeButtonColumn.append(removeButton); - row.append(removeButtonColumn); - - // input - const platformInput = document.createElement("input"); - platformInput.className = SettingsBuilder.inputClassNames.input; - platformInput.style.width = "100%"; - platformInput.oninput = saveData; - if(value) platformInput.value = value; - - const platformInputColumn = document.createElement("th"); - platformInputColumn.style.width = "100%"; - platformInputColumn.append(platformInput); - row.append(platformInputColumn); - - if(insertAtEnd) table.append(row); - else table.insertBefore(row, table.children[1]); - - id++; - } - addRowButton.onclick = () => addRow(); - - const removeRow = (id) => { - table.removeChild(document.getElementById(id)); - if(table.children.length === 1) addRow(); - saveData(); - } - - if(theList.length <= 1) { - addRow(theList[0]); - } else { - for(const item of theList) { - addRow(item, true); - } - } - - // add divider - const divider = document.createElement("div"); - divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.divider); - divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.dividerDefault); - listDiv.append(divider); - - return listDiv; - } -} - -/************* - ** STEAM ** - *************/ - -class Steam extends Platform { - - constructor() { - super("steam"); - - this.presenceCache = {}; - - this.loadData(); - if(this.enabled) { - this.start(); - } - } - - start() { - if(this.apiKey) { - this.updateCache(); - - // we are allowed 100000 requests/day = 64/minute - this.cacheUpdateinterval = setInterval(this.updateCache.bind(this), 10_000); - } - } - - serializeData() { - return { - enabled: this.enabled || false, - apiKey: this.apiKey || "", - usersMap: this.discordToSteamIDs || {}, - debug: this.debug || false - } - } - - deserializeData(data) { - this.enabled = data.enabled || false; - this.apiKey = data.apiKey || ""; - this.discordToSteamIDs = data.usersMap || {}; - this.debug = data.debug || false; - } - - async getPlayerSummaries(ids) { - const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${this.apiKey}&steamids=${ids.join(',')}`; - const req = await fetch(url); - - if(req.statusCode !== 200) { - console.error(req); - if(req.statusCode === 403) { - console.error(req); - if(this.apiKey) BdApi.alert("Your Steam API key is invalid! Steam has been disabled, reenable it in settings."); - else BdApi.alert("You haven't provided a Steam API key!"); - this.destroy(); - return; - } - console.error("HTTP error " + req.statusCode + " when fetching steam data"); - return; - } - - try { - const json_data = JSON.parse(req.body); - if (!json_data.response || !json_data.response.players) return; - this.log(json_data); - for (const playerSummary of json_data.response.players) { - this.processPlayerSummary(playerSummary); - } - } catch (e) { - console.error(e); - console.error(req); - err("Couldn't JSON Parse Steam response!"); - } - } - - processPlayerSummary(summary) { - // format: https://developer.valvesoftware.com/wiki/Steam_Web_API#GetPlayerSummaries_.28v0002.29 - if(summary.gameextrainfo) { - const statuses = ["Offline", "Playing", "Busy", "Away", "Snoozed", "Looking to trade", "Looking to play"]; - const previousPresence = this.presenceCache[summary.steamid]; - const playingSameGame = previousPresence && summary.gameextrainfo === previousPresence.name; - - const presence = { - application_id: customRpcAppId, - name: summary.gameextrainfo, - details: `${statuses[summary.personastate]} on Steam`, - type: 0, - timestamps: {start: playingSameGame ? previousPresence.timestamps.start : +new Date()}, - assets: { - large_image: "883490890377756682", - large_text: "Playing as " + summary.personaname - }, - username: summary.personaname, - priority: Priorities.NONPRIMARY_PLAYING - }; - - this.presenceCache[summary.steamid] = presence; - this.log(presence); - - if(!playingSameGame) this.updateUser(summary.steamid, this.discordToSteamIDs); - } else { - this.deletePresence(summary.steamid); - } - } - - deletePresence(id) { - if(this.presenceCache[id]) { - delete this.presenceCache[id]; - this.updateUser(id, this.discordToSteamIDs); - } - } - - updateCache() { - if(!this.enabled) return clearInterval(this.cacheUpdateinterval); - try { - const steam_ids = Object.values(this.discordToSteamIDs).flat(); - - // can only request 100 steam profiles at a time - for (let i = 0; i < steam_ids.length; i += 100) { - this.getPlayerSummaries(steam_ids.slice(i, i + 100)); - } - } catch (e) { - err(e); - } - } - - getPresence(discord_id) { - return super.getPresence(discord_id, this.discordToSteamIDs, this.presenceCache); - } - - getSettings(discordUserList, discordUsersDatalist) { - // enabled switch - const enabledSwitch = SettingsBuilder.enabledSwitch(this); - - // api key textbox - const textboxChange = (value) => { - this.apiKey = value; - if(this.enabled) { - SettingsBuilder.toggleEnabledSwitch(enabledSwitch); - } - } - const apiKeyTextbox = new ZeresPluginLibrary.Settings.Textbox("API Key", "Your Steam API key. Get one at https://steamcommunity.com/dev/apikey", this.apiKey, textboxChange); - setTimeout(() => { - apiKeyTextbox.getElement().children[0].children[2].innerHTML = `Your Steam API key. Get one at https://steamcommunity.com/dev/apikey` - }, 50); - - const userMapDiv = SettingsBuilder.userMapInterface(this, null, discordUsersDatalist, null, discordUserList, this.discordToSteamIDs, - "To get IDs, use a site such as Steam ID Finder and copy the SteamID64 (Dec).", "Steam ID", /^\d+$/); - - const debugSwitch = SettingsBuilder.debugSwitch(this); - - return SettingsBuilder.settingsPanel(this, enabledSwitch, apiKeyTextbox, userMapDiv, debugSwitch); - } - - destroy(pluginShutdown) { - this.enabled = false; - for(const id in this.presenceCache) this.deletePresence(id); - clearInterval(this.cacheUpdateinterval); - if(!pluginShutdown) this.saveData(); - } -} - -/***************** - ** MINECRAFT ** - *****************/ - -class Minecraft extends Platform { - constructor() { - super("minecraft"); - - this.mcUUIDToUsername = {}; - - this.loadData(); - this.UUIDs = [...new Set(Object.values(this.discordToMinecraftUUIDs).flat())] // remove duplicates - - this.log = this.log.bind(this); - this.updateUser = uuid => super.updateUser(uuid, this.discordToMinecraftUUIDs); - this.hypixel = new Hypixel(this.UUIDs, this.hypixelApiKey, this); - this.private = new MCPrivate(this.UUIDs, this.servers, this); - - if(this.enabled) { - this.start(); - } - } - - start() { - this.fetchUsernames().then(() => { - this.hypixel.start(); - }); - this.private.start(); - } - - loadData() { - const data = BdApi.loadData(pluginName, "minecraft"); - if(data) super.loadData(); - else { - // fetch data from old version of CrossPlatformPlaying - const data = BdApi.loadData(pluginName, "hypixel"); - if(data) { - this.enabled = data.enabled || false; - this.hypixelApiKey = data.apiKey || ""; - this.discordToMinecraftUUIDs = data.usersMap || {}; - this.debug = data.debug || false; - } - this.saveData(); - this.loadData(); - } - } - - serializeData() { - return { - enabled: this.enabled || false, - hypixelApiKey: this.hypixelApiKey || "", - usersMap: this.removeDashesFromUUIDs(this.discordToMinecraftUUIDs || {}), - servers: this.servers || [], - debug: this.debug || false - } - } - - deserializeData(data) { - this.enabled = data.enabled || false; - this.hypixelApiKey = data.hypixelApiKey || ""; - this.discordToMinecraftUUIDs = data.usersMap || {}; - this.servers = data.servers || []; - this.debug = data.debug || false; - } - - removeDashesFromUUIDs(discordToMinecraftUUIDs) { - for(const UUIDs of Object.values(discordToMinecraftUUIDs)) { - for(const [index, UUID] of Object.entries(UUIDs)) { - UUIDs[index] = UUID.replaceAll(/-/g, ""); - } - } - return discordToMinecraftUUIDs; - } - - getPresence(discord_id) { - const uuids = this.discordToMinecraftUUIDs[discord_id]; - if (!uuids) return; - - const presences = []; - - for (const uuid of uuids) { - const hypixelPresence = this.hypixel.getPresence(uuid); - if(hypixelPresence) presences.push(hypixelPresence); - - const privatePresences = this.private.getPresence(uuid); - if(privatePresences) presences.push(...privatePresences); - } - - if(presences.length) return presences; - } - - destroy(pluginShutdown) { - this.enabled = false; - this.hypixel.destroy(); - this.private.destroy(); - if(!pluginShutdown) this.saveData(); - } - - async fetchUsernames() { - await Promise.all(this.UUIDs.map(this.fetchUsername.bind(this))); - } - - async fetchUsername(UUID) { - // https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape - const req = await fetch(`https://sessionserver.mojang.com/session/minecraft/profile/${UUID}`); - const json = JSON.parse(req.body); - this.mcUUIDToUsername[UUID] = json.name; - } - - getSettings(discordUserList, discordUsersDatalist) { - // enabled switch - const enabledSwitch = SettingsBuilder.enabledSwitch(this); - - // api key textbox - const textboxChange = (value) => { - this.apiKey = value; - if(this.enabled) { - SettingsBuilder.toggleEnabledSwitch(enabledSwitch); - } - } - const apiKeyTextbox = new ZeresPluginLibrary.Settings.Textbox("Hypixel API Key", "Your Hypixel API key. Use the /api command in-game to get it.", this.hypixelApiKey, textboxChange); - - // uuid regex from https://github.com/bribes/Minecraft-UUID-RegEx - const userMapDiv = SettingsBuilder.userMapInterface(this, null, discordUsersDatalist, null, discordUserList, this.discordToMinecraftUUIDs, - "To get UUIDs, use NameMC.", "Minecraft UUID", /^([0-9a-f]{8})(?:-|)([0-9a-f]{4})(?:-|)(4[0-9a-f]{3})(?:-|)([89ab][0-9a-f]{3})(?:-|)([0-9a-f]{12})$/); - // todo make them username instead of UUID - // todo make it work when adding/removing users (changing hypixel timeout, etc.) - - const serverListDiv = SettingsBuilder.list(this, this.servers, - "A list of Minecraft servers to regularly query. Only works for small private servers, and only when there are less than ~12 players online.", "Server URL"); - - const debugSwitch = SettingsBuilder.debugSwitch(this); - - return SettingsBuilder.settingsPanel(this, enabledSwitch, apiKeyTextbox, userMapDiv, serverListDiv, debugSwitch); - } -} - - -/*************** - ** HYPIXEL ** - ***************/ - -class Hypixel { - - constructor(UUIDs, apiKey, mc) { - this.UUIDs = UUIDs; - this.apiKey = apiKey; - this.log = mc.log; - this.updateUser = mc.updateUser; - - this.timeouts = []; - this.presenceCache = {}; - this.games = { - MAIN: {name: "Main Lobby", modes: []} - }; - } - - start() { - if(this.apiKey) { - this.fetchGames().then(() => { - this.updateCache(); - this.cacheInterval = setInterval(this.updateCache.bind(this), this.calculateRefreshInterval()); - }); - } - } - - getPresence(uuid) { - return this.presenceCache[uuid]; - } - - destroy() { - for(const uuid in this.presenceCache) this.deletePresence(uuid); - clearInterval(this.cacheInterval); - for(const timeout of this.timeouts) - clearTimeout(timeout); - } - - async fetchGames() { - const modes_req = await fetch("https://raw.githubusercontent.com/slothpixel/hypixelconstants/master/build/modes.json"); - const modes = JSON.parse(modes_req.body); - - const extract_modes = game => { - if(!game.modes) return []; - let modes = {}; - for(const mode of game.modes) { - if(mode.modes) modes = {...modes, ...extract_modes(mode)} - else modes[mode.key] = mode.name; - } - return modes; - } - - for(const game of modes) { - this.games[game.key] = { - name: game.check || game.name, - modes: extract_modes(game) - } - } - - // slothpixel is slow to update when new games come out (e.g. seasonal minigames) - const hypixel_req = await fetch("https://api.hypixel.net/resources/games"); - const hypixel_modes = JSON.parse(hypixel_req.body); - if(!hypixel_modes.success) return; - - for(const game_id of Object.keys(hypixel_modes.games)) { - const game = hypixel_modes.games[game_id]; - const game_data = this.games[game_id] || {name: game.name, modes: {}}; - - game_data.modes = { - ...game.modeNames, - ...game_data.modes - } - - this.games[game_id] = game_data; - } - } - - calculateRefreshInterval() { - // hypixel allows 2 requests per second - // each player takes 1 request if offline, 2 if online - // to be safe, I do max 1 request / 2 sec -> 1 player / 4 sec - // aka if there are 3 players, we request all players every 12 seconds - // return this.UUIDs.length * 4000; - return this.UUIDs.length * 4000; - - } - - async getPlayerStatus(uuid) { - const url = `https://api.hypixel.net/status?key=${this.apiKey}&uuid=${uuid}`; - const req = await fetch(url); - - if(req.statusCode !== 200) { - console.error(req); - if(req.statusCode === 403) { - if(this.apiKey) BdApi.alert("Your Hypixel API key is invalid! The Hypixel plugin has been disabled, reenable it in settings."); - else BdApi.alert("You haven't provided a Hypixel API key!"); - this.destroy(); - } - else console.error("HTTP error " + req.statusCode + " when fetching hypixel player status data"); - return; - } - - try { - const json_data = JSON.parse(req.body); - if(json_data.success && json_data.session.online) { - this.log(json_data); - this.getPlayerInfo(uuid, json_data.session); - } else { - this.deletePresence(uuid); - if(!json_data.success) { - console.error(json_data); - err("Could not fetch player status for player with UUID " + uuid + "!"); - } - } - } catch (e) { - console.error(req); - console.error(e); - err("Couldn't JSON Parse Hypixel status response!"); - } - } - - async getPlayerInfo(uuid, session) { - const url = `https://api.hypixel.net/player?key=${this.apiKey}&uuid=${uuid}`; - const req = await fetch(url); - - if(req.statusCode !== 200) { - console.error("HTTP error " + req.statusCode + " when fetching hypixel player info", req); - return; - } - - try { - const json_data = JSON.parse(req.body); - if (json_data.success) { - this.log(json_data); - this.processPlayerData(uuid, json_data.player, session); - } else { - console.error(json_data); - err("Could not fetch player info for player with UUID " + uuid + "!"); - } - } catch (e) { - console.error(e); - console.error(req); - err("Couldn't JSON Parse Hypixel player response!"); - } - } - - processPlayerData(uuid, player, session) { - try { - const format = s => { - const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1); - return "(?) " + s.split(/[ _]/).map(capitalize).join(' '); - } - - const game = this.games[session.gameType] || {}; - const mode = session.mode === "LOBBY" ? "In the Lobby" : - game.modes ? game.modes[session.mode] : - format(session.mode); - - const presence = { - application_id: customRpcAppId, - name: "Hypixel", - details: "Playing " + game.name || format(session.gameType), - state: mode, - type: 0, - timestamps: {start: player.lastLogin}, - assets: { - large_image: "883490391964385352", - large_text: "Playing as " + player.displayname, - }, - username: player.displayname - }; - - if(session.map) { - presence.assets.small_image = "883498326580920403"; - presence.assets.small_text = session.map; - } - - const previousPresence = this.presenceCache[uuid]; - if(!previousPresence) this.updateUser(uuid); - - this.presenceCache[uuid] = presence; - this.log(presence); - } catch(e) { - console.error(e); - console.error(player, session); - err("Error while processing Hypixel data!"); - } - } - - deletePresence(uuid) { - if(this.presenceCache[uuid]) { - delete this.presenceCache[uuid]; - this.updateUser(uuid); - } - } - - updateCache() { - try { - for (let i = 0; i < this.UUIDs.length; i++) { - this.timeouts.push(setTimeout(() => { - this.getPlayerStatus(this.UUIDs[i]); - this.timeouts.shift(); - }, 4000 * i)); - } - } catch (e) { - err(e); - } - } -} - -/****************** - ** MC PRIVATE ** - ******************/ - -class MCPrivate { - - constructor(UUIDs, servers, mc) { - this.UUIDs = UUIDs; - this.servers = servers; - this.log = mc.log; - this.updateUser = mc.updateUser; - - this.presenceCache = {}; - this.pingTimeouts = []; - this.bufferedMessages = {}; - } - - start() { - this.pingServers(); - this.interval = setInterval(this.pingServers.bind(this), 30_000); - } - - pingServers() { - for(const url of this.servers) { - this.pingServer(url); - } - } - - parseServerAddress(url) { - const colonIndex = url.indexOf(':'); - if(colonIndex === -1) return [url, 25565]; - return[url.substring(0, colonIndex), parseInt(url.substring(colonIndex + 1))]; - } - - pingServer(url) { - const [address, port] = this.parseServerAddress(url); - const socket = net.createConnection(port, address); - socket.on("data", (data) => { - clearTimeout(timeout); - removeFromList(this.pingTimeouts, timeout); - - const roundTrip = Date.now() - sentAt; - this.log(`Ping to ${url} took ${roundTrip}ms`); - - this.parseData(data, url); - socket.end(); - }); - - // handshake - const handshake = []; - handshake.push(this.varInt(47)) // protocol version (1.8) - handshake.push(this.utfString(address)); - handshake.push(this.shortInt(port)); - handshake.push(this.varInt(1)); // status query (as opposed to login) - this.sendPayload(socket, 0, Buffer.concat(handshake)); - - // request - this.sendPayload(socket, 0, Buffer.from([])); - - // in case of no response - const sentAt = Date.now(); - let timeout = setTimeout(() => { - removeFromList(this.pingTimeouts, timeout); - this.log(`Server ${url} didn't respond within 5 seconds! Assuming the server is down`); - this.clearServerPresences(url); - }, 5000); - this.pingTimeouts.push(timeout); - } - - varInt(int) { - // https://wiki.vg/Protocol#VarInt_and_VarLong - const bytes = []; - while (true) { - if ((int & ~0x7F) === 0) { - bytes.push(int); - return Buffer.from(bytes); - } - bytes.push((int & 0x7F) | 0x80); - int >>>= 7; - } - } - - utfString(str) { - return Buffer.concat([this.varInt(str.length), Buffer.from(str, 'utf-8')]); - } - - shortInt(int) { - const buf = Buffer.alloc(2); - buf.writeUInt16BE(int); - return buf; - } - - sendPayload(socket, packetId, payload) { - packetId = this.varInt(packetId); - const packetLength = this.varInt(packetId.length + payload.length); - const packet = Buffer.concat([packetLength, packetId, payload]); - socket.write(packet); - } - - readVarInt (buf, start=0) { - // https://github.com/PassTheMayo/minecraft-server-util/blob/master/src/util/varint.ts#L1 - let value = 0; - let i = start; - let currentByte; - - do { - currentByte = buf[i]; - value |= ((currentByte & 0x7F) << (7 * (i - start))); - i++; - } while ((currentByte & 0x80) !== 0); - - return [value, i]; - } - - parseData(buf, url) { - try { - if(this.bufferedMessages[url]) { - buf = Buffer.concat([this.bufferedMessages[url], buf]); - delete this.bufferedMessages[url]; - } - - const [packetLength, i] = this.readVarInt(buf); - if(buf.length < packetLength) { - this.log(`Received chunk of data from ${url}, waiting for the rest...`); - this.bufferedMessages[url] = buf; - return; - } - - const [packetID, j] = this.readVarInt(buf, i); - const [JSONLength, k] = this.readVarInt(buf, j); - const unparsed = buf.slice(k, k + JSONLength).toString(); - - const data = JSON.parse(unparsed); - this.log(data); - - this.processData(data, url); - } catch(e) { - console.error(buf); - console.error(e); - err("Failed to parse server data for " + url); - } - } - - processData(data, url) { - const players = data.players.sample || []; - for(const player of players) { - const uuid = player.id.replaceAll('-', ""); - let previousPresence; - if(this.presenceCache[uuid] === undefined) this.presenceCache[uuid] = {}; - else previousPresence = this.presenceCache[uuid][url]; - - const presence = { - application_id: customRpcAppId, - name: "Minecraft", - details: "Playing on " + url, - state: "In Game", - party: { - id: url, - size: [data.players.online, data.players.max] - }, - type: 0, - timestamps: { - start: previousPresence ? previousPresence.timestamps.start : Date.now() - }, - assets: { - large_image: `url:https://crafatar.com/renders/head/${uuid}?overlay=true`, - large_text: data.version.name, - small_image: data.favicon ? "url:" + data.favicon : null, - small_text: (data.description.text || data.description.toString() || "").replaceAll(/( {2})|\n/g, ' ').replaceAll(/ยง./g, '') - }, - username: player.name - }; - - if(previousPresence) presence.timestamps.start = previousPresence.timestamps.start; - else this.updateUser(uuid); - - this.presenceCache[uuid][url] = presence; - this.log(presence); - } - - // clear players not on server anymore - const UUIDs = players.map(player => player.id.replaceAll('-', "")); - for(const uuid in this.presenceCache) { - if(!UUIDs.includes(uuid)) { - this.deletePresence(uuid); - } - } - } - - deletePresence(uuid) { - if(this.presenceCache[uuid]) { - delete this.presenceCache[uuid]; - this.updateUser(uuid); - } - } - - clearServerPresences(url) { - for(const presences of Object.values(this.presenceCache)) - delete presences[url]; - } - - getPresence(uuid) { - if(this.presenceCache[uuid]) return Object.values(this.presenceCache[uuid]); - } - - destroy() { - clearInterval(this.interval); - for(const uuid in this.presenceCache) this.deletePresence(uuid); - } -} - -/************** - ** TWITCH ** - **************/ - -class Twitch extends Platform { - - constructor() { - super("twitch"); - - this.presenceCache = {}; - this.usersList = { - idToName: {}, - nameToId: {} - } - - this.loadData(); - if(this.enabled) { - this.start(); - } - } - - start() { - if(this.oauthKey) { - this.getOnlineFriends(); - this.interval = setInterval(this.getOnlineFriends.bind(this), 60_000); - } - } - - serializeData() { - return { - enabled: this.enabled || false, - oauthKey: this.oauthKey || "", - usersMap: this.discordToTwitchID || {}, - debug: this.debug || false - } - } - - deserializeData(data) { - this.enabled = data.enabled || false; - this.oauthKey = data.oauthKey || ""; - this.discordToTwitchID = data.usersMap || {}; - this.debug = data.debug || false; - } - - destroy(pluginShutdown) { - this.enabled = false; - for(const id in this.presenceCache) this.deletePresence(id); - clearInterval(this.interval); - if(!pluginShutdown) this.saveData(); - } - - async getOnlineFriends() { - if(!this.enabled || !this.oauthKey) return this.destroy(); - - const data = await fetch("https://gql.twitch.tv/gql", { - method: "POST", - headers: { - "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko", - "Authorization": `OAuth ${this.oauthKey}` - }, - body: JSON.stringify([{operationName: "OnlineFriends", variables: {}, extensions: {persistedQuery: {version: 1, sha256Hash: "4fecfced6ce413ffa2eee3c6ce09cddd8fb251763c594c26fba5108cf2b92e69"}}}]) - }); - - try { - if(data.body.length <= 2) return; - const json_data = JSON.parse(data.body); - if (json_data.status === 401) { - this.destroy(); - console.error(json_data); - return err("Error 401 when fetching twitch friends!"); - } - - if(json_data.status === 502) { - console.error(json_data); - return console.error("Error 502 when fetching twitch friends!"); - } - - if(json_data[0].errors) { - console.error(json_data); - if(["service timeout", "service error", "service unavailable"].includes(json_data[0].errors[0].message)) return; - console.error(data); - return err("Twitch friends request returned error!"); - - } - - if(!json_data[0].data.currentUser) { - this.destroy(); - console.error(json_data); - return err("Twitch friends request returned no currentUser! Is your account active?"); - } - - this.log(json_data); - - const streamerList = this.extractStreamerList(json_data[0].data.currentUser.friends.edges); - const streamsMetadata = await this.fetchStreamsMetadata(streamerList); - if(!streamerList || !streamsMetadata) return; - - this.usersList = { - idToName: {}, - nameToId: {} - } - for (const friend of json_data[0].data.currentUser.friends.edges) { - this.processFriend(friend.node, streamsMetadata); - } - } catch (e) { - console.error(e); - console.error(data); - err("Couldn't JSON Parse Twitch response!", data); - } - } - - extractStreamerList(friend_edges) { - const streamerLogins = []; - for(const edge of friend_edges) { - if(edge.node.activity && - edge.node.activity.type === "WATCHING" && - edge.node.activity.user.stream && - edge.node.activity.user.login && - !streamerLogins.includes(edge.node.activity.user.login)) - streamerLogins.push(edge.node.activity.user.login) - } - return streamerLogins; - } - - async fetchStreamsMetadata(streamerLogins) { - const requestBody = []; - for(const streamerLogin of streamerLogins) { - requestBody.push({ - "operationName": "StreamMetadata", - "variables": {"channelLogin": streamerLogin}, - "extensions": { - "persistedQuery": { - "version": 1, - "sha256Hash": "059c4653b788f5bdb2f5a2d2a24b0ddc3831a15079001a3d927556a96fb0517f" - } - } - }); - } - - const data = await fetch("https://gql.twitch.tv/gql", { - method: "POST", - headers: { - "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko", - "Authorization": "OAuth " + this.oauthKey - }, - body: JSON.stringify(requestBody) - }); - const body = JSON.parse(data.body); - - try { - const streamsMetadata = {}; - for(let i = 0; i < body.length; i++) { - const stream = body[i].data.user; - streamsMetadata[streamerLogins[i]] = { - title: stream.lastBroadcast.title, - viewers: stream.stream.viewersCount, - start: +new Date(stream.stream.createdAt), - profilePicture: stream.profileImageURL//.replace("70x70", "300x300") // 600x600 also works - } - } - - return streamsMetadata; - } catch(e) { - console.error(e); - console.error(data); - console.error(body); - err("Error while trying to fetch twitch metadata!"); - } - } - - processFriend(friend, streamsMetadata) { - try { // add friend to usersList - this.usersList.idToName[friend.id] = friend.login; - this.usersList.nameToId[friend.login] = friend.id; - - if(friend.activity) { - let previousPresence = this.presenceCache[friend.id]; - if(previousPresence) previousPresence = previousPresence(); - if(friend.activity.type === "WATCHING") { - if(!friend.activity.user.stream || !friend.activity.user.stream.game) // they are watching someone that is no longer streaming (their presence hasn't updated yet) - return delete this.presenceCache[friend.id]; - - const isWatchingSamePerson = previousPresence && previousPresence.details.substr(9) === friend.activity.user.displayName; - const away = friend.availability === "AWAY"; - const metadata = streamsMetadata[friend.activity.user.login]; - - // the way type 3 (watching) presences are rendered is weird - // the large_text is shown both when hovering the image but also underneath the details (this is because that's how spotify Listening activities are rendered) - // in the "Active Now" tab in the friends list it only says "watching a stream" instead of rendering the whole activity like type 0 (playing) does - // the only reason type 3 exists in the first place is for YouTube Together as far as I can tell, so not much thought has been put into it - - // also timestamps (01:23:45 elapsed) aren't rendered, so the presences are stored as functions - // when the presence is requested, the function is called and parses the current time into the large_text - - this.presenceCache[friend.id] = () => { - return { - application_id: away ? customRpcAppId : null, - name: "Twitch", - details: `Watching ${friend.activity.user.displayName}`, - state: metadata.title, - type: 3, - party: {id: friend.activity.user.login}, - timestamps: {start: isWatchingSamePerson ? previousPresence.timestamps.start : +new Date()}, - assets: { - large_image: away ? "899072297216913449" : "twitch:" + friend.activity.user.login, - large_text: `${friend.activity.user.stream.game.displayName} | ๐Ÿ‘ค ${metadata.viewers} | ๐Ÿ• ${this.parseStartTime(metadata.start)}`, - small_image: "url:" + metadata.profilePicture, - small_text: friend.activity.user.displayName - }, - username: friend.displayName, - priority: Priorities.SECONDARY - } - } - } else if(friend.activity.type === "STREAMING") this.presenceCache[friend.id] = () => { - return { - name: "Twitch", - state: friend.activity.stream.game.displayName, - type: 1, - assets: { - large_image: "twitch:" + friend.login, - large_text: "As " + friend.displayName - }, - url: "https://twitch.tv/" + friend.login, - id: "", - username: friend.displayName, - priority: Priorities.NONPRIMARY_PLAYING // should this be above the actual game presence? - } - } - - this.log(this.presenceCache[friend.id]()); - - if(!previousPresence) this.updateUser(friend.id, this.discordToTwitchID); - } else { - this.deletePresence(friend.id); - } - } catch(e) { - console.error(friend, streamsMetadata); - console.error(e); - err("Error while processing Twitch friend data! " + e); - } - } - - deletePresence(id) { - if(this.presenceCache[id]) { - delete this.presenceCache[id]; - this.updateUser(id, this.discordToTwitchID); - } - } - - parseStartTime(start) { - let streamingFor = Date.now() - start; - streamingFor -= streamingFor % 5000; // to mitigate discord's weird polling/rendering frequency - const sec = Math.floor(streamingFor / 1000 % 60), - min = Math.floor(streamingFor / 1000 / 60 % 60), - hour = Math.floor(streamingFor / 1000 / 60 / 60); - - const p = n => n.toString().padStart(2, '0'); // 5:3 -> 05:03 - - if(hour) return `${hour}:${p(min)}:${p(sec)}`; - return `${p(min)}:${p(sec)}`; - } - - getPresence(discord_id) { - const presenceFunctions = super.getPresence(discord_id, this.discordToTwitchID, this.presenceCache); - if(presenceFunctions && presenceFunctions.length) return presenceFunctions.map(presenceFunction => presenceFunction()); - } - - getSettings(discordUserList, discordUsersDatalist) { - // enabled switch - const enabledSwitch = SettingsBuilder.enabledSwitch(this); - - // api key textbox - const textboxChange = (value) => { - this.oauthKey = value; - if(this.enabled) { - SettingsBuilder.toggleEnabledSwitch(enabledSwitch); - } - } - const apiKeyTextbox = new ZeresPluginLibrary.Settings.Textbox("OAuth Key", "How to get your Twitch OAuth key", this.oauthKey, textboxChange); - setTimeout(() => { - apiKeyTextbox.getElement().children[0].children[2].innerHTML = `How to get your Twitch OAuth key` - }, 50); - - const datalist = SettingsBuilder.createDatalist("twitch", Object.keys(this.usersList.nameToId)); - const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, this.usersList, discordUserList, this.discordToTwitchID, null, "Twitch username", /^\d+$/); - - const debugSwitch = SettingsBuilder.debugSwitch(this); - - return SettingsBuilder.settingsPanel(this, enabledSwitch, apiKeyTextbox, userMapDiv, debugSwitch); - } -} - -/************ - ** RIOT ** - ************/ - -class Riot extends Platform { - - constructor() { - super("riot"); - - this.riotPUUIDToUsername = {}; - this.riotPUUIDToSummonerName = {}; - - const log = this.log.bind(this); - const updateUser = puuid => super.updateUser(puuid, this.discordToRiotPUUIDs); - - this.valorant = new Valorant(this.riotPUUIDToUsername, log, updateUser); - this.lol = new Lol(this.riotPUUIDToUsername, this.riotPUUIDToSummonerName, log, updateUser); - this.wildRift = new WildRift(this.riotPUUIDToUsername, this.riotPUUIDToSummonerName, log, updateUser); - - this.loadData(); - if (this.enabled) { - this.start(); - } - } - - start() { - if(this.cookies) { - this.valorant.fetchRpcAssets() - this.lol.fetchData(); - this.startXMPPConnection(); - } - } - - serializeData() { - return { - enabled: this.enabled || false, - cookies: this.cookies || "", - usersMap: this.discordToRiotPUUIDs || {}, - debug: this.debug || false - } - } - - deserializeData(data) { - this.enabled = data.enabled || false; - this.cookies = data.cookies || ""; - this.discordToRiotPUUIDs = data.usersMap || {}; - this.debug = data.debug || false; - } - - - getPresence(discord_id) { - const puuids = this.discordToRiotPUUIDs[discord_id]; - if (!puuids) return; - - const presences = []; - - for (const puuid of puuids) { - const valPresence = this.valorant.getPresence(puuid); - if(valPresence) presences.push(valPresence); - - const lolPresence = this.lol.getPresence(puuid); - if(lolPresence) presences.push(lolPresence); - - const wrPresence = this.wildRift.getPresence(puuid); - if(wrPresence) presences.push(wrPresence); - } - - if(presences.length) return presences; - } - - destroy(pluginShutdown) { - this.enabled = false; - clearInterval(this.reconnectInterval); - clearTimeout(this.heartbeat); - if(this.socket) { - this.socket.write(""); - this.socket.destroy(); - } - - this.valorant.deleteAllPresences(); - this.lol.deleteAllPresences(); - this.wildRift.deleteAllPresences(); - - if(!pluginShutdown) this.saveData(); - } - - async refreshToken(cookies) { - const res = await fetch("https://auth.riotgames.com/authorize?" + - "redirect_uri=https%3A%2F%2Fplayvalorant.com%2Fopt_in&" + - "client_id=play-valorant-web-prod&" + - "response_type=token%20id_token&" + - "scope=account%20ban%20link%20lol%20offline_access%20openid&" + - "nonce=123", { - method: "GET", - headers: { - // "cloudflare bitches at us without a user-agent" - molenzwiebel - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36", - "cookie": cookies - }, - }); - this.updateCookies(res.headers['set-cookie']); - const uri = res.headers.location; - return uri.split(/[=&]/, 2)[1]; - } - - updateCookies(newCookies) { - const cookies = {}; - - // parse old cookies - for(const cookie of this.cookies.split("; ")) { - const split = cookie.split('='); - cookies[split.shift()] = split.join("="); - } - - // replace with new cookies - for(const cookie of newCookies) { - const sep = cookie.indexOf("="); - cookies[cookie.slice(0, sep)] = cookie.slice(sep + 1, cookie.indexOf(';')); - } - - const cookieList = []; - for (let [key, value] of Object.entries(cookies)) { - cookieList.push(key + "=" + value); - } - - this.cookies = cookieList.join("; "); - this.saveData(); - } - - getCookiesFromLauncher() { - // I doubt this works on Mac - const filepath = process.env.LOCALAPPDATA + "/Riot Games/Riot Client/Data/RiotGamesPrivateSettings.yaml"; - - let fileContents; - try { - fileContents = fs.readFileSync(filepath).toString(); - } catch(e) { - return [false, e]; - } - - const parsedContents = this.parseYaml(fileContents); - if(parsedContents) { - const cookies = []; - for(const cookie of parsedContents["riot-login"].persist.session.cookies) { - cookies.push(cookie.name + '=' + cookie.value); - } - return [true, cookies.join("; ")]; - } - - return [false, "Could not parse file! Is it corrupt?"]; - } - - parseYaml(yaml) { - const result = {}; - const path = []; - let depth = 0; - - let currentObject = result; - let currentObjectName = ""; - - for(const line of yaml.split('\n')) { - if(!line) continue; - - const array_matches = line.match(/^( {4})*(- {3})(.+):( (.+))?/); - if(array_matches) { - let array = path[path.length - 1]; - currentObject = {}; - if(Array.isArray(array)) { - array.push(currentObject); - } else { - array = [currentObject]; - path[path.length - 1][currentObjectName] = array; - path.push(array); - } - - currentObject[array_matches[3]] = JSON.parse(array_matches[5]); - continue; - } - - const matches = line.match(/^(( {4})*)(.+):( (.+))?/); - if(matches) { - while((matches[1] && matches[1].length || 0) / 4 < depth) { - currentObject = path.pop(); - depth--; - } - - if(matches[4]) { - currentObject[matches[3]] = JSON.parse(matches[5]); - } else { - let newObject = {}; - currentObjectName = matches[3]; - currentObject[currentObjectName] = newObject; - path.push(currentObject); - currentObject = newObject; - depth++; - } - continue; - } - - console.error("no matches for line: " + line); - return false; - } - - return result; - } - - async getPAS(token) { - const res3 = await fetch("https://riot-geo.pas.si.riotgames.com/pas/v1/service/chat", { - method: "GET", - headers: { - "Authorization": "Bearer " + token, - }, - }); - return res3.body - } - - decodeToken(token) { - return JSON.parse(atob(token.split('.')[1])); - } - - // xmpp stuff - establishXMPPConnection(RSO, PAS) { - try { - this.presenceCache = {}; - - const region = this.decodeToken(PAS).affinity; - const address = this.XMPPRegionURLs[region]; - const port = 5223; - const XMPPRegion = this.XMPPRegions[region]; - - const messages = [ - ``, "", - `${RSO}${PAS}`, - ``, "", - "", - "", - "", // get friends list - "" - ] - - const sock = tls.connect(port, address, {}, () => { - try { - this.log("Connected!"); - - clearInterval(this.reconnectInterval); - this.reconnectInterval = null; - - sendNext(); - } catch (e) { - err(e); - } - }); - this.socket = sock; - - const send = data => { - try { - if(sock.readyState === "open") sock.write(data, "utf8", () => { - if(data !== " ") this.log("-> " + data) - }); - - clearTimeout(this.heartbeat); - this.heartbeat = setTimeout(() => send(" "), 150_000); - } catch (e) { - err(e); - } - } - - const sendNext = () => send(messages.shift()); - - let bufferedMessage = ""; - - sock.on("data", data => { - try { - data = data.toString(); - this.log("<- " + data); - if(messages.length > 0) sendNext(); - - // handle riot splitting messages into multiple parts - if(data.startsWith("')).split(' ', 1)[0]; - - // check for self closing tag eg - if(data.search(/<[^<>]+\/>/) === 0) data = data.replace("/>", `>`); - - let closingTagIndex = data.indexOf(``); - if(closingTagIndex === -1) { - // message is split, we need to wait for the end - bufferedMessage = data; - break; - } - - // check for tag inside itself eg - // this happens when you send a message to someone - let containedTags = 0; - let nextTagIndex = data.indexOf(`<${firstTagName}`, 1); - while(nextTagIndex !== -1 && nextTagIndex < closingTagIndex) { - containedTags++; - nextTagIndex = data.indexOf(`<${firstTagName}`, nextTagIndex + 1); - } - - while(containedTags > 0) { - closingTagIndex = data.indexOf(``, closingTagIndex + 1); - containedTags--; - } - - const firstTagEnd = closingTagIndex + ``.length; - bufferedMessage = data.substr(firstTagEnd); // will be empty string if only one tag - data = data.substr(0, firstTagEnd); - - if(firstTagName === "presence") { - this.valorant.processXMLData(data); - this.lol.processXMLData(data); - this.wildRift.processXMLData(data); - } else if(firstTagName === "iq") { - if(data.includes("jabber:iq:riotgames:roster")) { - this.processFriendsList(data); - } else if(data.includes("_xmpp_session") && data.includes("urn:ietf:params:xml:ns:xmpp-session")) { - this.processOwnUsername(data, this.decodeToken(PAS).sub); - } - } else if(firstTagName === "failure") { - const errorStartIndex = data.indexOf('>') + 1; - const error = data.substring(errorStartIndex, closingTagIndex); - if(data.includes("token-expired")) { - // BdApi.alert("token expired!") - this.log(error); - this.restart(); - } else { - console.error(data); - err("Riot XMPP Connection failed! " + data); - this.destroy(); - } - } - - data = ""; - } - } catch (e) { - err(e); - } - }); - - sock.on("error", console.error); - sock.on("close", () => { - if(!this.enabled) return this.log("Socket disconnected!"); - - console.error("Riot Connection Closed! Retrying in 5 seconds..."); - - if(this.reconnectInterval) return; - - this.reconnectInterval = setInterval(() => { - this.log("Reconnecting..."); - this.establishXMPPConnection(RSO, PAS); - }, 5000); - - clearTimeout(this.heartbeat); - }); - } catch (e) { - err(e); - } - } - - async startXMPPConnection() { - // todo do not hardcode these, get these from clientconfig endpoint - this.XMPPRegions = {"as2":"as2","asia":"jp1","br1":"br1","eu":"ru1","eu3":"eu3","eun1":"eu2","euw1":"eu1","jp1":"jp1","kr1":"kr1","la1":"la1","la2":"la2","na1":"na1","oc1":"oc1","pbe1":"pb1","ru1":"ru1","sea1":"sa1","sea2":"sa2","sea3":"sa3","sea4":"sa4","tr1":"tr1","us":"la1","us-br1":"br1","us-la2":"la2","us2":"us2"}; - this.XMPPRegionURLs = {"as2":"as2.chat.si.riotgames.com","asia":"jp1.chat.si.riotgames.com","br1":"br.chat.si.riotgames.com","eu":"ru1.chat.si.riotgames.com","eu3":"eu3.chat.si.riotgames.com","eun1":"eun1.chat.si.riotgames.com","euw1":"euw1.chat.si.riotgames.com","jp1":"jp1.chat.si.riotgames.com","kr1":"kr1.chat.si.riotgames.com","la1":"la1.chat.si.riotgames.com","la2":"la2.chat.si.riotgames.com","na1":"na2.chat.si.riotgames.com","oc1":"oc1.chat.si.riotgames.com","pbe1":"pbe1.chat.si.riotgames.com","ru1":"ru1.chat.si.riotgames.com","sea1":"sa1.chat.si.riotgames.com","sea2":"sa2.chat.si.riotgames.com","sea3":"sa3.chat.si.riotgames.com","sea4":"sa4.chat.si.riotgames.com","tr1":"tr1.chat.si.riotgames.com","us":"la1.chat.si.riotgames.com","us-br1":"br.chat.si.riotgames.com","us-la2":"la2.chat.si.riotgames.com","us2":"us2.chat.si.riotgames.com"}; - - const access_token = await this.refreshToken(this.cookies); - if(!access_token.startsWith('e')) { - this.log("Riot Access Token: " + access_token); - err("Invalid Riot access token! Most likely your cookies are either invalid or expired."); - return this.destroy(); - } - - const pas_token = await this.getPAS(access_token); - if(!access_token.startsWith('e')) { - this.log("Invalid Riot PAS: " + pas_token); - // riot sometimes returns error 520 when fetching PAS - // note: this is a very dangerous workaround - return setTimeout(this.startXMPPConnection.bind(this), 1000); - } - - if(!this.discordToRiotPUUIDs[discord_id]) - this.discordToRiotPUUIDs[discord_id] = [this.decodeToken(pas_token).sub]; - - this.establishXMPPConnection(access_token, pas_token); - } - - static extractDataFromXML(xml, tagName, startIndex, endIndex) { - const dataStartIndex = xml.indexOf(`<${tagName}>`, startIndex, endIndex); - const dataEndIndex = xml.indexOf(``, dataStartIndex, endIndex); - if(dataStartIndex >= 0 && dataEndIndex > dataStartIndex) { - const data = xml.substring(dataStartIndex + tagName.length + 2, dataEndIndex) - if(data) return data; - } - } - - processOwnUsername(data, puuid) { - const usernameIndex = data.indexOf("name=") + 6; - const username = data.substring(usernameIndex, data.indexOf("' ", usernameIndex)); - - const taglineIndex = data.indexOf("tagline=") + 9; - const tagline = data.substring(taglineIndex, data.indexOf("'/>", taglineIndex)); - - this.riotPUUIDToUsername[puuid] = `${username}#${tagline}`; - this.log("My username is " + this.riotPUUIDToUsername[puuid]); - - const lolTagIndex = data.indexOf(" -1) { - const lolNameIndex = data.indexOf("name=", lolTagIndex) + 6; - const lolName = data.substring(lolNameIndex, data.indexOf("'", lolNameIndex)); - - this.riotPUUIDToSummonerName[puuid] = lolName - this.log("My summoner name is " + lolName); - } - } - - processFriendsList(data) { - const queryTag = data.substring(data.indexOf("") + 42, data.indexOf("")); - const items = queryTag.split(""); - - for(const item of items) { - if(!item) continue; - - const puuid = item.substr(11, 36); - - // riot ID - const idTagIndex = item.indexOf("", taglineIndex)); - - this.riotPUUIDToUsername[puuid] = `${username}#${tagline}`; - - // lol summoner name - const lolTagIndex = item.indexOf(" -1) { - const lolNameIndex = item.indexOf("name=", lolTagIndex) + 6; - const lolName = item.substring(lolNameIndex, item.indexOf("'", lolNameIndex)); - - this.riotPUUIDToSummonerName[puuid] = lolName; - } - } - this.log(this.riotPUUIDToUsername); - this.log(this.riotPUUIDToSummonerName); - } - - getSettings(discordUserList, discordUsersDatalist) { - // enabled switch - const enabledSwitch = SettingsBuilder.enabledSwitch(this); - - // cookies textbox - const textboxChange = (value) => { - this.cookies = value; - if(this.enabled) { - SettingsBuilder.toggleEnabledSwitch(enabledSwitch); - } - } - const buttonClick = () => { - const [success, cookies] = this.getCookiesFromLauncher(); - if(success) { - this.cookies = cookies; - cookiesTextbox.children[0].children[1].children[0].value = cookies; - } else { - err(cookies); - } - } - const cookiesTextbox = SettingsBuilder.textboxWithButton("Auth Cookies", "Your auth.riotgames.com cookies. Will only work if you're currently logged in with 'Remember me', and until you log out. Otherwise, use this guide.", - this.cookies, textboxChange, {}, "Fetch cookies from launcher", buttonClick); - setTimeout(() => { - cookiesTextbox.children[0].children[1].children[0].removeAttribute("maxlength"); - cookiesTextbox.children[0].children[2].innerHTML = "Your auth.riotgames.com cookies. Will only work if you're currently logged in with 'Remember me', and until you log out. Otherwise, use this guide." - }, 50); - - const usersList = { - idToName: this.riotPUUIDToUsername, - nameToId: {} - } - for(const [puuid, username] of Object.entries(this.riotPUUIDToUsername)) { - usersList.nameToId[username] = puuid; - } - const datalist = SettingsBuilder.createDatalist("riot", Object.keys(usersList.nameToId)); - const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, usersList, discordUserList, this.discordToRiotPUUIDs, - "If you see numbers instead of names, it's either because the friends list hasn't loaded, or you are no longer friends with them.", "Riot ID", /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/); - - const debugSwitch = SettingsBuilder.debugSwitch(this); - - return SettingsBuilder.settingsPanel(this, enabledSwitch, cookiesTextbox, userMapDiv, debugSwitch); - } -} - -/**************** - ** VALORANT ** - ****************/ - -const valRpcAppID = "811469787657928704"; // https://github.com/colinhartigan/valorant-rpc - -class Valorant { - constructor(riotPUUIDToUsername, log, updateUser) { - this.riotPUUIDToUsername = riotPUUIDToUsername; - this.log = log; - this.updateUser = updateUser; - - // constants - // todo get these from valorant-api - this.gamemodes = { - "newmap": "New Map", - "competitive": "Competitive", - "unrated": "Unrated", - "spikerush": "Spike Rush", - "deathmatch": "Deathmatch", - "ggteam": "Escalation", - "onefa": "Replication", - "snowball": "Snowball Fight", - "custom": "Custom", - "": "Custom" - } - this.maps = { - "Triad": "Haven", - "Duality": "Bind", - "Bonsai": "Split", - "Port": "Icebox", - "Ascent": "Ascent", - "Foxtrot": "Breeze", - "Canyon": "Fracture", - "Pitt": "Pearl", - "Range": "The Range" - } - this.ranks = [ - 'UNRANKED', 'Unused1', 'Unused2', - 'IRON 1', 'IRON 2', 'IRON 3', - 'BRONZE 1', 'BRONZE 2', 'BRONZE 3', - 'SILVER 1', 'SILVER 2', 'SILVER 3', - 'GOLD 1', 'GOLD 2', 'GOLD 3', - 'PLATINUM 1', 'PLATINUM 2', 'PLATINUM 3', - 'DIAMOND 1', 'DIAMOND 2', 'DIAMOND 3', - 'IMMORTAL 1', 'IMMORTAL 2', 'IMMORTAL 3', - 'RADIANT', 'Better than Radiant' - ] - - this.presenceCache = {}; - this.assets = {}; - } - - async fetchRpcAssets() { - const assetsReq = await fetch(`https://discord.com/api/v9/oauth2/applications/${valRpcAppID}/assets`); - const assetList = JSON.parse(assetsReq.body); - for(const asset of assetList) { - // format: {"id": "821415501942882324", "type": 2, "name": "splash_icebox_square"} - this.assets[asset.name] = asset.id; - } - } - - processXMLData(data) { - try { - const puuid = data.substr(16, 36); - - // extract valorant presence - const valorantData = Riot.extractDataFromXML(data, "valorant"); - if(valorantData) { - const base64Data = Riot.extractDataFromXML(valorantData, "p"); - const timestamp = parseInt(Riot.extractDataFromXML(valorantData, "s.t")); - try { - const presenceData = JSON.parse(atob(base64Data)); - this.processPresenceData(puuid, presenceData, timestamp); - } catch (e) { - console.error(data); - err("Could not JSON parse Valorant presence data! " + e); - } - } else { - const previousPresence = this.presenceCache[puuid]; - delete this.presenceCache[puuid]; - if(previousPresence) this.updateUser(puuid); - } - } catch (e) { - console.error(data); - err(e); - } - } - - processPresenceData(puuid, presenceData, timestamp) { - this.log(presenceData); - - try { - const parseDate = s => { - const year = parseInt(s.substr(0, 4)), - month = parseInt(s.substr(5, 2)) - 1, - day = parseInt(s.substr(8, 2)), - hour = parseInt(s.substr(11, 2)), - minute = parseInt(s.substr(14, 2)), - second = parseInt(s.substr(17, 2)); - if (year === 1) return timestamp; - return +Date.UTC(year, month, day, hour, minute, second); - } - - const previousPresence = this.presenceCache[puuid]; - const map = presenceData.provisioningFlow === "ShootingRange" ? "Range" : presenceData.matchMap.split('/').pop(); - const username = this.riotPUUIDToUsername[puuid]; - - const getGamemode = () => { - if(presenceData.queueId === "") { - if(previousPresence && previousPresence.details.startsWith("In Queue - ")) return previousPresence.details.substr(11); - return "Custom"; - } - const modeName = this.gamemodes[presenceData.queueId]; - return modeName || presenceData.queueId; - } - /* A bit of backstory: - * On 21/09/2021 Riot removed map data from the presence, so you could no longer - * know which map your friend was playing on. - * Except they forgot to remove it during agent select. So the plugin would store - * the map data when they were in agent select, and "remember" it during the - * actual match, even though Riot didn't send it anymore. - * (This wouldn't work if the plugin was started mid-match or for deathmatch). - * Then on 20/10/2021, they mysteriously put it back. No idea if it's here to stay, - * but having the extra code doesn't hurt :) - */ - const getMapName = () => { - const mapName = this.maps[map] || map; - if(mapName) return mapName; - else { - // no map data, get map name from previous presence - if(previousPresence) { - const previousMapName = previousPresence.assets.large_text.split(" | ")[0]; - if(previousMapName !== "No map data" && previousMapName !== "Lobby") - return previousMapName; - } - } - return "No map data"; - } - const getMapIcon = () => { - if(map === "" && presenceData.sessionLoopState === "INGAME") { - // no map data, get map icon from previous presence - if(previousPresence && previousPresence.assets.large_image !== this.assets["game_icon"] && previousPresence.assets.large_image !== this.assets["game_icon_yellow"]) - return previousPresence.assets.large_image; - } - const mapCodename = this.maps[map]; - if(!mapCodename) return this.assets["game_icon_white"]; - return this.assets[`splash_${map === "Range" ? "range" : mapCodename.toLowerCase()}_square`]; - } - const getPartyText = () => `${presenceData.partyAccessibility === "OPEN" ? "Open" : "Closed"} Party${presenceData.isPartyOwner && presenceData.partySize > 1 ? " Leader" : ""}`; - const getStartTimestamp = () => { - if(presenceData.sessionLoopState === "MENUS") { - if(presenceData.partyState === "MATCHMAKING") - return parseDate(presenceData.queueEntryTime); // in queue, return start queue time - if(previousPresence && (previousPresence.details.startsWith("Lobby - ") || previousPresence.details === "Setting Up Custom Game")) - return previousPresence.timestamps.start; - return timestamp; - } else if(presenceData.sessionLoopState === "INGAME") { - if(previousPresence && previousPresence.details.startsWith("Custom")) - return previousPresence.timestamps.start; - if(presenceData.provisioningFlow === "ShootingRange") { - if(previousPresence && previousPresence.provisioningFlow === "ShootingRange") - return previousPresence.timestamps.start; - return timestamp; - } - } - - return parseDate(presenceData.queueEntryTime) || timestamp; - } - - const presenceBoilerplate = { - application_id: valRpcAppID, - name: "VALORANT", - type: 0, - state: getPartyText(), - party: { - id: presenceData.partyId, - size: [presenceData.partySize, presenceData.maxPartySize || (previousPresence ? previousPresence.party.size[1]: 69)] - }, - timestamps: { - start: getStartTimestamp() - }, - assets: { - small_image: this.assets["rank_" + presenceData.competitiveTier] || this.assets["rank_0"], - small_text: `${this.ranks[presenceData.competitiveTier]}${presenceData.leaderboardPosition > 0 ? ` #${presenceData.leaderboardPosition}` : ""} | LVL ${presenceData.accountLevel}` - }, - username: username, - priority: Priorities.PLAYING - }; - let presence = {}; - - switch (presenceData.sessionLoopState) { - case "MENUS": - // in lobby or in queue - const menusGetDetails = () => { - if(presenceData.partyState === "DEFAULT") return `Lobby - ${getGamemode()}`; - if(presenceData.partyState === "MATCHMAKING") return `In Queue - ${getGamemode()}`; - if(presenceData.partyState === "MATCHMADE_GAME_STARTING") return `Match Found - ${getGamemode()}`; - if(presenceData.partyState === "CUSTOM_GAME_SETUP") return `Setting Up Custom Game`; - return `${presenceData.partyState} (?) - ${getGamemode()}`; - } - const menusGetLargeImage = () => { - if(presenceData.isIdle) return this.assets["game_icon_yellow"]; - if(presenceData.partyState === "CUSTOM_GAME_SETUP") return getMapIcon(presenceData.matchMap); - if(presenceData.playerCardId) return `url:https://media.valorant-api.com/playercards/${presenceData.playerCardId}/smallart.png`; - return this.assets["game_icon"]; - } - const menusGetLargeText = () => (presenceData.isIdle ? "Away" : "Lobby"); - - presence = { - ...presenceBoilerplate, - details: menusGetDetails(), - assets: { - ...presenceBoilerplate.assets, - large_image: menusGetLargeImage(), - large_text: menusGetLargeText(), - }, - priority: presenceData.isIdle ? Priorities.IN_LOBBY_AFK : Priorities.IN_LOBBY - } - break; - case "PREGAME": - // in agent select - const pregameGetDetails = () => { - if(presenceData.provisioningFlow === "Invalid") return `${getGamemode()} - Match Found`; - if(presenceData.provisioningFlow === "CustomGame") return `Custom Game - Agent Select`; - if(presenceData.provisioningFlow === "Matchmaking") return `${getGamemode()} - Agent Select`; - return `${getGamemode()} - ${presenceData.provisioningFlow} (?)`; - } - - presence = { - ...presenceBoilerplate, - details: pregameGetDetails(), - assets: { - ...presenceBoilerplate.assets, - large_image: getMapIcon(), - large_text: (presenceData.isIdle ? "Away" : getMapName()) - } - } - - if(pregameGetDetails().endsWith("Match Found")) // match found 5sec timer - presence.timestamps.end = timestamp + 5000; - else if(pregameGetDetails().endsWith("Agent Select")) // start 75sec countdown once agent select has loaded (~79sec including loading time) - presence.timestamps.end = timestamp + 79000; - break; - case "INGAME": - const ingameGetDetails = () => { - const gamemode = getGamemode(); - if(presenceData.partyState === "MATCHMADE_GAME_STARTING") { // deathmatch skips pregame - presenceBoilerplate.timestamps.end = timestamp + 5000; - return `Match Found - ${gamemode}`; - } - - let s = ""; - - if(gamemode === "Custom" && presenceData.customGameTeam === "TeamSpectate") - s += "Spectating "; - - if(map === "Range") - s += presenceData.provisioningFlow === "NewPlayerExperience" ? "Tutorial" : "The Range"; - else - s += gamemode; - - if(presence.partyOwnerMatchCurrentTeam === "") - s += " - Loading"; - else if(map !== "Range") - s += ` ${presenceData.partyOwnerMatchScoreAllyTeam}-${presenceData.partyOwnerMatchScoreEnemyTeam}`; - - return s; - } - presence = { - ...presenceBoilerplate, - details: ingameGetDetails(), - assets: { - ...presenceBoilerplate.assets, - large_image: getMapIcon(), - large_text: (presenceData.isIdle ? "Away" : getMapName()) // + (username ? " | " + username : ""), - } - } - break; - } - - this.presenceCache[puuid] = presence; - this.log(presence); - - if(!previousPresence || presence.priority !== previousPresence.priority) { - this.updateUser(puuid); - } - } catch (e) { - console.error(puuid, presenceData, timestamp); - err(e); - } - } - - getPresence(puuid) { - return this.presenceCache[puuid]; - } - - deleteAllPresences() { - for(const puuid in this.presenceCache) { - if(this.presenceCache[puuid]) { - delete this.presenceCache[puuid]; - this.updateUser(puuid); - } - } - } -} - - -/*********** - ** LOL ** - ***********/ - -const lolShowSkinName = true; - -const lolRpcAppId = "899030985855860756"; - -class Lol { - constructor(riotPUUIDToUsername, riotPUUIDToSummonerName, log, updateUser) { - this.riotPUUIDToUsername = riotPUUIDToUsername; - this.riotPUUIDToSummonerName = riotPUUIDToSummonerName; - - this.gameVersion = ""; // will be overridden by lolGetLatestVersion() - this.champions = {}; - this.skins = {}; - this.assets = { // to be filled in by fetchRpcAssets() - logo_lol: "899053983879008266", - logo_tft: "899054046294462494", - champions: {}, - ranks: {} - } - this.queues = { // manually adding in gamemodes not in riot json - 1130: { - "queueId": 1130, - "map": "Convergence", - "description": "Teamfight Tactics Hyper Roll", - "notes": "Added in manually" - }, - 1150: { - "queueId": 1150, - "map": "Convergence", - "description": "Teamfight Tactics Double Up", - "notes": "Added in manually" - }, - 1160: { - "queueId": 1160, - "map": "Convergence", - "description": "Teamfight Tactics Double Up (Workshop)", - "notes": "Added in manually" - } - } - this.maps = {}; - - this.presenceCache = {}; - this.log = log; - this.updateUser = updateUser; - } - - processXMLData(data) { - try { - const puuid = data.substr(16, 36); - - // extract lol presence - const lolData = Riot.extractDataFromXML(data, "league_of_legends"); - if(lolData) { - const presenceUnparsed = Riot.extractDataFromXML(lolData, "p"); - if (presenceUnparsed) { - // regalia is an object within the object, causes issues with parser - const presenceHalfParsed = presenceUnparsed.replace(/"/g, '"').replace(/'/g, '"').replace(/"regalia":.+}",/, ""); - try { - const presenceData = JSON.parse(presenceHalfParsed); - const timestamp = Riot.extractDataFromXML(lolData, "s.t"); - const status = Riot.extractDataFromXML(lolData, "st"); - this.log(presenceData); - this.processPresenceData(puuid, presenceData, status, timestamp); - } catch(e) { - console.error(data); - err("Could not JSON parse Lol presence data!" + e); - } - } - } else { - const previousPresence = this.presenceCache[puuid]; - delete this.presenceCache[puuid]; - if(previousPresence) this.updateUser(puuid); - } - } catch (e) { - console.error(data); - err(e); - } - } - - async fetchGameVersion() { - const versionsReq = await fetch("https://ddragon.leagueoflegends.com/api/versions.json"); - this.gameVersion = JSON.parse(versionsReq.body)[0]; - } - - async fetchChampionData() { - const championsReq = await fetch(`https://ddragon.leagueoflegends.com/cdn/${this.gameVersion}/data/en_US/champion.json`); - const champsData = JSON.parse(championsReq.body); - for(const champ of Object.values(champsData.data)) { - this.champions[champ.key] = {name: champ.name, id: champ.id}; - } - } - - async fetchRpcAssets() { - const assetsReq = await fetch(`https://discord.com/api/v9/oauth2/applications/${lolRpcAppId}/assets`); - const assetList = JSON.parse(assetsReq.body); - for(const asset of assetList) { - // format: {"id": "403244850960662538", "type": 2, "name": "aatrox"} - if(asset.name === "logo") this.assets.logo_lol = asset.id; - else if(asset.name === "tft_logo") this.assets.logo_tft = asset.id; - else if(asset.name.startsWith("rank_")) this.assets.ranks[asset.name.substr(5)] = asset.id; - else this.assets.champions[asset.name] = asset.id; - } - } - - async fetchQueueTypes() { - const queueTypesReq = await fetch("https://static.developer.riotgames.com/docs/lol/queues.json"); - const queueTypes = JSON.parse(queueTypesReq.body); - for(const queue of queueTypes) { - if(queue.description) queue.description = queue.description.replace(" games", ""); - this.queues[queue.queueId] = queue; - } - } - - async fetchMapsData() { - const mapsReq = await fetch("https://static.developer.riotgames.com/docs/lol/maps.json"); - const maps = JSON.parse(mapsReq.body); - for(const map of maps) { - this.maps[map.mapId.toString()] = { - name: map.mapName, - notes: map.notes - }; - } - } - - fetchData() { - this.fetchGameVersion().then(() => { - this.fetchRpcAssets(); - this.fetchMapsData(); - this.fetchChampionData(); - this.fetchQueueTypes(); - }); - } - - async getSkinName(champ_id, skin_id) { - // doesn't work with chromas atm - if(!lolShowSkinName) return null; - - if(!this.skins[champ_id]) { - const req = await fetch(`https://ddragon.leagueoflegends.com/cdn/${this.gameVersion}/data/en_US/champion/${champ_id}.json`); - this.skins[champ_id] = JSON.parse(req.body).data[champ_id].skins; - } - - const skins = this.skins[champ_id]; - - for(const skin of Object.values(skins)) { - if(skin.id === skin_id) { - if(skin.name === "default") return null; - return skin.name; - } - } - } - - async processPresenceData(puuid, data, status, timestamp) { - try { - const username = this.riotPUUIDToSummonerName[puuid] || this.riotPUUIDToUsername[puuid]; - timestamp = Math.max(data.timeStamp, timestamp) || data.timeStamp || timestamp; - - let gamemode, map; - if(data.mapId in this.maps) map = this.maps[data.mapId].name; - if(data.gameQueueType === "PRACTICETOOL") [gamemode, map] = ["Practice Tool", "Summoner's Rift"]; - else if (data.queueId && this.queues[data.queueId]) { - const gamemodeData = this.queues[data.queueId]; - [gamemode, map] = [gamemodeData.description, gamemodeData.map]; - } else if (data.queueId === "-1") gamemode = "Custom"; - else if (data.gameStatus === "outOfGame") gamemode = "In the Lobby" - else gamemode = `(?) ${data.queueId} ${data.gameQueueType} ${data.gameStatus}`; - - const isAway = status === "away"; - - const previousPresence = this.presenceCache[puuid]; - - let presenceBoilerplate = { - application_id: lolRpcAppId, - name: data.gameMode === "TFT" ? "Teamfight Tactics" : "League of Legends", - type: 0, - details: gamemode, - assets: { - large_image: data.gameMode === "TFT" ? this.assets.logo_tft : this.assets.logo_lol, //`url:https://ddragon.leagueoflegends.com/cdn/${this.gameVersion}/img/profileicon/${data.profileIcon}.png`, - large_text: `Level ${data.level} | Mastery ${data.masteryScore}`, - small_image: data.rankedLeagueTier ? this.assets.ranks[data.rankedLeagueTier.toLowerCase()] : null, - small_text: data.rankedLeagueTier ? `${data.rankedLeagueTier} ${data.rankedLeagueDivision}` : null - - }, - timestamps: { - start: timestamp - }, - username: username, - priority: Priorities.PLAYING - }; - let presence; - - // if open party, process party data - if (data.pty) { - const partyData = JSON.parse(data.pty); - presenceBoilerplate.party = { - id: partyData.partyId, - size: [partyData.summoners.length, 5] - } - presenceBoilerplate.state = "Open Party" - } else if(data.gameId) { - presenceBoilerplate.party = { - id: data.gameId, - } - } - - if (data.gameStatus !== "inGame" && ["SCOUTING", "LOCKED_IN"].includes(data.clashTournamentState)) { - presence = { - ...presenceBoilerplate, - details: "Clash", - state: data.clashTournamentState === "SCOUTING" ? "Scouting" : "Locked In" - } - } else if (data.gameStatus === "outOfGame") { - presence = { - ...presenceBoilerplate, - details: isAway ? "Main Menu (away)" : "In the Main Menu", - priority: isAway ? Priorities.IN_LOBBY_AFK : Priorities.IN_LOBBY, - inLobby: true - } - if(previousPresence && previousPresence.inLobby) - presence.timestamps.start = previousPresence.timestamps.start; - } else if (data.gameStatus.startsWith("hosting_")) { - presence = { - ...presenceBoilerplate, - state: isAway ? "In the Lobby (away)" : "In the Lobby", - priority: isAway ? Priorities.IN_LOBBY_AFK : Priorities.IN_LOBBY, - inLobby: true - } - if(data.gameStatus === "hosting_Custom") - presence.details = "Custom"; - if(previousPresence && previousPresence.inLobby) - presence.timestamps.start = previousPresence.timestamps.start; - } else if (data.gameStatus === "inQueue") { - presence = { - ...presenceBoilerplate, - state: "In Queue", - timestamps: { - start: data.timeStamp - } - } - } else if (data.gameStatus === "championSelect") { - presence = { - ...presenceBoilerplate, - state: "In Champion Select" - } - } else if (data.gameStatus === "inGame") { - const champion = this.champions[data.championId]; - const inGameGetLargeImage = () => { - if(champion && this.assets.champions[champion.id.toLowerCase()]) - return this.assets.champions[champion.id.toLowerCase()]; - return presenceBoilerplate.assets.large_image; - } - const inGameGetLargeText = async () => { - return await this.getSkinName(champion.id, data.skinVariant) || champion.name; - } - presence = { - ...presenceBoilerplate, - state: "In Game" + (map ? ` (${map})` : ""), - timestamps: { - start: data.timeStamp - }, - assets: { - ...presenceBoilerplate.assets, - large_image: champion ? inGameGetLargeImage() : presenceBoilerplate.assets.large_image, - large_text: champion ? await inGameGetLargeText() : presenceBoilerplate.assets.large_text - } - } - } else { - console.error(data); - console.error("Unknown LoL gameStatus! " + data.gameStatus); - } - - - if (presence) { - this.presenceCache[puuid] = presence; - this.log(presence); - - if(!previousPresence || presence.priority !== previousPresence.priority) { - this.updateUser(puuid); - } - } - } catch (e) { - console.error(puuid, data, timestamp) - err(e); - } - } - - getPresence(puuid) { - return this.presenceCache[puuid]; - } - - deleteAllPresences() { - for(const puuid in this.presenceCache) { - if(this.presenceCache[puuid]) { - delete this.presenceCache[puuid]; - this.updateUser(puuid); - } - } - } -} - - -/***************** - ** WILD RIFT ** - *****************/ - -class WildRift { - constructor(riotPUUIDToUsername, riotPUUIDToSummonerName, log, updateUser) { - this.riotPUUIDToUsername = riotPUUIDToUsername; - this.riotPUUIDToSummonerName = riotPUUIDToSummonerName; - this.presenceCache = {}; - this.log = log; - this.updateUser = updateUser; - } - - processXMLData(data) { - try { - const puuid = data.substr(16, 36); - - // extract wild rift presence - const wrData = Riot.extractDataFromXML(data, "wildrift"); - if(wrData) { - this.log(wrData); - try { - const timestamp = parseInt(Riot.extractDataFromXML(wrData, "s.t")); - const username = Riot.extractDataFromXML(data, "m"); - this.processPresenceData(puuid, timestamp, username); - } catch (e) { - console.error(wrData); - err("Could not parse Wild Rift presence data!" + e); - } - } else { - const previousPresence = this.presenceCache[puuid]; - delete this.presenceCache[puuid]; - if(previousPresence) this.updateUser(puuid); - } - } catch (e) { - console.error(data); - err(e); - } - } - - processPresenceData(puuid, timestamp, username) { - username = username || this.riotPUUIDToSummonerName[puuid] || this.riotPUUIDToUsername[puuid]; - - let presence = { - application_id: customRpcAppId, - name: "League of Legends: Wild Rift", - type: 0, - assets: { - large_image: "889119952911630386", - large_text: "Playing as " + username - }, - timestamps: { - start: timestamp - }, - username: username, - priority: Priorities.PLAYING - }; - - const previousPresence = this.presenceCache[puuid]; - - this.presenceCache[puuid] = presence; - this.log(presence); - - if(!previousPresence) { - this.updateUser(puuid); - } - } - - getPresence(puuid) { - return this.presenceCache[puuid]; - } - - deleteAllPresences() { - for(const puuid in this.presenceCache) { - if(this.presenceCache[puuid]) { - delete this.presenceCache[puuid]; - this.updateUser(puuid); - } - } - } -} - - -/************ - ** EPIC ** - ************/ - -class Epic extends Platform { - constructor() { - super("epic"); - - this.presenceCache = {}; - this.statusCache = {}; - - this.epicIdToDisplayName = {}; - - this.fortniteGamemodes = {}; - this.fortniteRpcAppId = "432980957394370572"; - - this.loadData(); - if(this.enabled) { - this.start(); - } - } - - async start() { - if(!this.authData.refresh) { - return this.destroy(); - } - const success = await this.authenticate(); - if(success) { - await Promise.all([this.fetchFortniteGamemodes(), this.fetchFortniteAssets()]); - this.establishXMPPConnection(this.authData.token); - } else { - this.destroy(); - } - } - - serializeData() { - return { - enabled: this.enabled || false, - authData: this.authData || {}, - usersMap: this.discordToEpicID || {}, - debug: this.debug || false - } - } - - deserializeData(data) { - this.enabled = data.enabled || false; - this.authData = data.authData || {}; - this.discordToEpicID = data.usersMap || {}; - this.debug = data.debug || false; - } - - getPresence(discord_id) { - return super.getPresence(discord_id, this.discordToEpicID, this.presenceCache); - } - - destroy(pluginShutdown) { - this.enabled = false; - for(const id in this.presenceCache) this.deletePresence(id); - clearTimeout(this.refreshTimeout); - clearInterval(this.reconnectInterval); - clearTimeout(this.heartbeat); - if(this.socket) { - try { - this.socket.send(""); - } catch(e) {console.error("Error while sending epic disconnect signal!", e)} - this.socket.close(1000); - } - if(!pluginShutdown) this.saveData(); - } - - restart() { - this.destroy(); - setTimeout(() => { - this.enabled = true; - this.saveData(); - this.start(); - }, 500); // sockets take a while to close - } - - async fetchFortniteGamemodes() { - const req = await fetch("https://fortnite-api.com/v1/playlists"); // big thanks to Officer and his excellent API - const json = JSON.parse(req.body); - for(const gamemode of json.data) { - this.fortniteGamemodes[gamemode.id.toLowerCase()] = { - name: gamemode.name === "CREATIVE MATCHMAKING" ? "Creative (Fill)" : gamemode.name || gamemode.id, // oh epic... - subName: gamemode.subName, - maxSquadSize: gamemode.maxSquadSize, - maxPlayers: gamemode.maxPlayers, - smallIcon: gamemode.images.missionIcon - } - } - - // arena duos has null name/subname for some reason - this.fortniteGamemodes["playlist_showdownalt_duos"] = { - name: "Arena", - subName: "Duos", - maxSquadSize: 2, - maxPlayers: 100, - smallIcon: "https://fortnite-api.com/images/playlists/playlist_showdownalt_duos/missionicon.png" - } - } - - async fetchFortniteAssets() { - const req = await fetch(`https://discord.com/api/v9/oauth2/applications/${this.fortniteRpcAppId}/assets`); - const json = JSON.parse(req.body); - for(const asset of json) { - if(asset.name === "fortnite") this.fortniteLogoAssetId = asset.id; - } - } - - decodeJWT(token) { - return JSON.parse(atob(token.split("~", 2)[1].split(".", 2)[1])); - } - - getID(token) { - return this.decodeJWT(token).sub; - } - - getDisplayName(token) { - return this.decodeJWT(token).dn; - } - - getTokenExpiry(token) { - return this.decodeJWT(token).exp * 1000; - } - - async authenticate() { - // returns true or false based on success - if(this.authData.refresh) { - if(this.authData.token) { - const expiry = this.getTokenExpiry(this.authData.token); - this.log("Token expiring at " + expiry); - if(expiry - new Date() > 300_000) { - // fetch friends list to see if token is valid - const success = await this.fetchFriendsList(); - if(success) { - // use the token - this.setupRefreshTimeout(); - return true; - } - } - } - - const refreshSuccess = await this.refreshToken(); - if(!refreshSuccess) return false; - - this.setupRefreshTimeout(); - return true; - } - - return false; - } - - async refreshToken() { - this.log("Refreshing token..."); - - // check refresh token is still valid - const refreshExpiry = this.decodeJWT(this.authData.refresh).exp * 1000; - if(refreshExpiry - new Date() < 5000) return false; - - const req = await fetch("https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token", { - method: "POST", - headers: { - Authorization: "basic MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=", - "Accept-Language": "en-EN", - "Content-Type": "application/x-www-form-urlencoded" - }, - body: `grant_type=refresh_token&refresh_token=${this.authData.refresh}&token_type=eg1&includePerms=false` - }); - const authData = JSON.parse(req.body); - if(authData.error) { - console.error(authData); - - const r = this.authData.refresh; - const errorMessage = r.length > 20 ? authData.errorMessage.replace(r, r.substr(0, 10) + "..." + r.substr(r.length - 10)) : r; - - err("EPIC: Could not refresh token! You may need to login again. " + errorMessage); - return false; - } - - this.authData = { - token: authData.access_token, - refresh: authData.refresh_token - } - this.saveData(); - - this.log("Refreshed token as " + authData.displayName); - - return true; - } - - async redeemExchangeCode(exchangeCode) { - // returns true or false based on success - const req = await fetch("https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token", { - method: "POST", - headers: { - Authorization: "basic MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=", - "Accept-Language": "en-EN", - "Content-Type": "application/x-www-form-urlencoded" - }, - body: `grant_type=exchange_code&exchange_code=${exchangeCode}&token_type=eg1&includePerms=false` - }); - - const authData = JSON.parse(req.body); - if(authData.error) { - err("EPIC: Could not redeem exchange code! " + authData.errorMessage); - return false; - } - this.log("Redeemed exchange code as " + authData.displayName); - - this.authData = { - token: authData.access_token, - refresh: authData.refresh_token - } - return true; - } - - async redeemAuthCode(authCode) { - // get auth code -> https://www.epicgames.com/id/api/redirect?clientId=34a02cf8f4414e29b15921876da36f9a&responseType=code - // returns true or false based on success - const req = await fetch("https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token", { - method: "POST", - headers: { - Authorization: "basic MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=", - "Accept-Language": "en-EN", - "Content-Type": "application/x-www-form-urlencoded" - }, - body: `grant_type=authorization_code&code=${authCode}&token_type=eg1&includePerms=false` - }); - - const authData = JSON.parse(req.body); - if(authData.error) { - err("Could not redeem auth code! " + authData.errorMessage); - return false; - } - this.log("Redeemed auth code as " + authData.displayName); - - this.authData = { - token: authData.access_token, - refresh: authData.refresh_token - } - - this.restart(); - - return true; - } - - setupRefreshTimeout() { - const tokenExpiry = this.decodeJWT(this.authData.token).exp * 1000; - this.refreshTimeout = setTimeout(this.refreshToken.bind(this), tokenExpiry - new Date() - 300_000); - } - - async fetchFriendsList() { - this.epicIdToDisplayName[this.getID(this.authData.token)] = this.getDisplayName(this.authData.token); - - // get friends list - const req = await fetch(`https://friends-public-service-prod.ol.epicgames.com/friends/api/v1/${this.getID(this.authData.token)}/summary?displayNames=true`, { - headers: { - Authorization: "Bearer " + this.authData.token - } - }); - const json = JSON.parse(req.body); - - if(req.statusCode !== 200) { - console.error(req); - console.error(this.authData); - - if(req.statusCode.toString().startsWith('5')) { - console.error(`Error ${req.statusCode} when trying to fetch friends list! Retrying in 5 seconds...`); - await new Promise(r => setTimeout(r, 5000)); - return await this.fetchFriendsList() - } else if(req.statusCode !== 401) { - err("Error while fetching Epic friends!"); - } - - return false; - } - - // get username of friends - // we can request in batches of 100, partition the list of IDs - const friendIDs = json.friends.map(friend => friend.accountId); - const reqs = []; - for (let i = 0; i < friendIDs.length; i += 100) { - const partition = friendIDs.slice(i, i + 100); - const req = fetch("https://account-public-service-prod.ol.epicgames.com/account/api/public/account?accountId=" + partition.join("&accountId="), { - headers: { - Authorization: "Bearer " + this.authData.token - } - }); - reqs.push(req); - } - - for(const req of reqs) { - const reqResult = await req; - if(reqResult.statusCode !== 200) { - console.error(reqResult); - err("Error while fetching Epic friends partition!"); - continue; - } - - const json = JSON.parse(reqResult.body); - for(const friend of json) { - this.epicIdToDisplayName[friend.id] = friend.displayName; - } - } - - this.log(this.epicIdToDisplayName); - return true; - } - - // xmpp stuff - establishXMPPConnection(token) { - try { - this.presenceCache = {}; - - const address = "xmpp-service-prod.ol.epicgames.com"; - const accountId = this.getID(token); - - const messages = [ - ``, '', - `${btoa("\0" + accountId + "\0" + token)}`, - ``, '', - `V2:launcher:WIN::`, - `` - ] - - const sock = new WebSocket(`wss://${address}`, "xmpp"); - sock.onopen = () => { - try { - this.log("Connected!"); - - clearInterval(this.reconnectInterval); - this.reconnectInterval = null; - - sendNext(); - this.heartbeat = setInterval(sendPing, 60_000); - } catch (e) { - err(e); - } - }; - this.socket = sock; - - const send = (data, log=true) => { - if(!data || !this.enabled) return; - try { - if(sock.readyState === 1) { - sock.send(data); - if(log) this.log("-> " + data); - } - } catch (e) { - err(e); - } - } - - const sendNext = () => send(messages.shift()); - - let lastPing = 0; - const sendPing = () => { - if(lastPing > 0 && Date.now() - lastPing > 150_000) { - this.log("Haven't received a ping back in 150sec, reconnecting..."); - return this.restart(); - } - - send("", false); - } - - sock.onmessage = event => { - try { - // const timestamp = performance.timeOrigin + event.timeStamp - const data = event.data; - - if(data.includes("acbeabf8-b04b-4e94-a044-6d6b8f04514e")) - lastPing = Date.now(); - else - this.log("<- " + data); - - if(data.startsWith(" 0) sendNext(); - - // process data - if(data.startsWith(" { - if(!this.enabled || sock !== this.socket) return this.log("Websocket disconnected!"); - console.error("Epic disconnected! Retrying in 5 seconds..."); - - if(this.reconnectInterval) return; - - this.reconnectInterval = setInterval(() => { - this.log("Reconnecting..."); - this.establishXMPPConnection(token); - }, 5000); - - clearTimeout(this.heartbeat); - }; - } catch (e) { - console.error(token); - err(e); - } - } - - processPresence(presence) { - const idStart = presence.indexOf("from=\"") + 6; - const idEnd = presence.indexOf("@", idStart); - const id = presence.substring(idStart, idEnd); - - const fullIdEnd = presence.indexOf("\"", idStart); - const fullId = presence.substring(idStart, fullIdEnd); - const presenceSource = fullId.split(":")[1]; - - let show; - const showStart = presence.indexOf("") + 6; - if(showStart > 5) { - const showEnd = presence.indexOf("", showStart); - show = presence.substring(showStart, showEnd); - } - - const statusStart = presence.indexOf("") + 8; - if(statusStart === 7) return; - const statusEnd = presence.indexOf("", statusStart); - const status_raw = presence.substring(statusStart, statusEnd); - const status = JSON.parse(status_raw); - this.log(status); - - let presenceType; - if(presence.includes("type=")) { - const presenceTypeStart = presence.indexOf("type=") + 6; - const presenceTypeEnd = presence.indexOf("\"", presenceTypeStart); - presenceType = presence.substring(presenceTypeStart, presenceTypeEnd); - - } - - let timestamp; - if(presence.includes(" in game - if(previousPresence.details !== currentStatus) presence.timestamps.start = + new Date(); - } - - break; - case "Fortnite": - try { - const gamemode = this.fortniteGamemodes[(status.Properties.GamePlaylistName_s || "").toLowerCase()] || {name: status.Properties.GamePlaylistName_s || "", maxSquadSize: 0, maxPlayers: 100}; - const partyData = Object.keys(status.Properties).filter(s => s.startsWith("party.joininfodata"))[0]; - - const detailsTemplate = (name, subName) => name + (subName ? ` (${subName})` : ""); - const stateTemplate = (kills, players, maxPlayers=100) => `๐Ÿ’€ ${kills} โ€‰๐Ÿ‘ค ${players}/${maxPlayers}`; // thin space because discord removes double spaces - - let details, state; - if(status.bIsPlaying) { - const kills = parseInt(status.Properties.FortGameplayStats_j.numKills); - if(status.bIsJoinable) { // creative - details = detailsTemplate(gamemode.name || "Creative", gamemode.subName); - - if(status.Properties.ServerPlayerCount_i) state = stateTemplate(kills, status.Properties.ServerPlayerCount_i, gamemode.maxPlayers || 16); - else state = "Loading"; - } else if(status.Properties.GamePlaylistName_s) { // battle royale - details = detailsTemplate(gamemode.name || status.Properties.GamePlaylistName_s, gamemode.subName); - - if(status.Properties.ServerPlayerCount_i) state = stateTemplate(kills, status.Properties.ServerPlayerCount_i); - else state = `Loading`; - } - if(status.Properties.FortGameplayStats_j.bFellToDeath) state += " - Died of fall damage"; - } else if (status.Properties.FortGameplayStats_j) { // in the lobby, hasn't launched a gamemode yet - if(gamemode.name) details = detailsTemplate(gamemode.name || status.Properties.GamePlaylistName_s, gamemode.subName); - if(show === "xa" || show === "away") state = "Away - In the Lobby"; - else state = "In the Lobby"; - } else { // game loading - details = "Loading"; - } - - const partySize = status.Properties.FortPartySize_i || status.Properties.Event_PartySize_s; - const maxPartySize = gamemode.maxSquadSize || (partySize <= 4 ? 4 : 16); - - presence = { - ...presenceBoilerplate, - application_id: this.fortniteRpcAppId, - name: "Fortnite", - details: details, - state: state, - assets: { - large_image: this.fortniteLogoAssetId, - small_image: gamemode.smallIcon && `url:${gamemode.smallIcon}`, - small_text: gamemode.subName - }, - party: { - id: status.SessionId, - size: [partySize, maxPartySize] - }, - timestamps: { - start: timestamp - }, - priority: Priorities.PLAYING, - gameId: status.SessionId - } - - if(previousPresence.gameId === presence.gameId) - presence.timestamps.start = previousPresence.timestamps.start; - - if(partyData) { - const partyInfo = status.Properties[partyData]; - if(partyInfo.sourceDisplayName) presence.username = partyInfo.sourceDisplayName; - if(partyInfo.partyId) presence.party.id = partyInfo.partyId; - } - } catch(e) { - console.error(presence); - console.error(status); - err(e); - } - - break; - default: // misc - if(presenceType === "unavailable") return this.deletePresence(id); - - let name; - switch(presenceSource) { - case "fghi4567eJdrrwo5Dgu1RiO2R0vM1XVK": - name = "Satisfactory"; - break; - case "fcb692f0fdf14526b1ffbb77cf1ef288": - name = "Paladins"; - break; - case "f71b1231985f48d1af3de723e0a6acdd": - name = "Smite"; - break; - case "fghi4567gDK32qevrArU3uezn7r9kY8Y": - name = "Rocket League Sideswipe"; - break; - case "68d2cc08f9a94b8fb51af4f5cfa6d41b": - name = "Grand Theft Auto V"; - break; - default: - if(this.debug) err("Unknown game ID! " + presenceSource); - else console.error("Unknown game ID! " + presenceSource); - return; - } - - presence = { - ...presenceBoilerplate, - name: name, - details: this.statusCache[id], - assets: { - large_image: epicLogoID - }, - timestamps: { - start: timestamp - } - } - } - - this.presenceCache[id] = presence; - this.log(presence); - - if(!previousPresence || presence.name !== previousPresence.name) { - this.updateUser(id, this.discordToEpicID); - } - } - - async getAppName(appID) { - // uses store search. definitely not the best way since it won't work on hidden/unlisted games. - if(appID === "fn") return "Fortnite"; - const req = await fetch("https://www.epicgames.com/graphql", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - query: "query searchStoreQuery($count: Int = 5, $keywords: String, $namespace: String, $category: String) {\n Catalog {\n searchStore(\n count: $count\n keywords: $keywords\n namespace: $namespace\n category: $category\n ) {\n elements {\n title\n namespace\n id\n }\n }\n }\n}\n", - variables: { - "namespace": appID, - "category": "games/edition/base|bundles/games|editors|software/edition/base" - } - }) - }); - - try { - return JSON.parse(req.body).data.Catalog.searchStore.elements[0].title; - } catch(e) { - err("Could not fetch app name from id!", req.body); - } - } - - getSettings(discordUserList, discordUsersDatalist) { - // enabled switch - const enabledSwitch = SettingsBuilder.enabledSwitch(this); - - // cookies textbox - const deleteButtonClick = () => { - this.authData = {}; - SettingsBuilder.getTextboxInput(tokenTextbox).value = ""; - if(this.enabled) SettingsBuilder.toggleEnabledSwitch(enabledSwitch); - this.saveData(); - } - const tokenTextbox = SettingsBuilder.textboxWithButton("Refresh Token", null, this.authData.refresh, null, {placeholder: "Refresh token", disabled: true}, "Delete", deleteButtonClick); - - const codeButtonClick = async (textbox, redeemFunction) => { - const code = SettingsBuilder.getTextboxInput(textbox).value; - if(!code) return; - - const button = textbox.children[0].children[1].children[1]; - button.classList.remove("bd-button-danger"); - button.innerHTML = "Redeeming..."; - - const success = await redeemFunction(code); - - if(success) { - button.innerHTML = "Success!"; - SettingsBuilder.getTextboxInput(tokenTextbox).value = this.authData.refresh; - SettingsBuilder.getTextboxInput(textbox).value = ""; - this.saveData(); - } else { - button.innerHTML = "Retry"; - button.classList.add("bd-button-danger"); - } - } - - const authCodeButtonClick = () => codeButtonClick(authCodeTextbox, this.redeemAuthCode.bind(this)); - const authCodeTextbox = SettingsBuilder.textboxWithButton(null, null, null, null, {placeholder: "Auth code"}, "Redeem auth code", authCodeButtonClick); - - const exchangeCodeButtonClick = () => codeButtonClick(exchangeCodeTextbox, this.redeemExchangeCode.bind(this)); - const exchangeCodeTextbox = SettingsBuilder.textboxWithButton(null, "If you're signed in in your browser, get your auth code here. Otherwise, generate an exchange code.", null, null, {placeholder: "Exchange code"}, "Redeem exchange code", exchangeCodeButtonClick); - setTimeout(() => { - exchangeCodeTextbox.children[0].children[2].innerHTML = `If you're signed in in your browser, get your auth code here. Otherwise, generate an exchange code.` - }, 50); - - // remove spacing between textboxes - setTimeout(() => { - tokenTextbox.children[0].children[3].classList.remove("divider-1Jfi9s"); - authCodeTextbox.children[0].children[3].classList.remove("divider-1Jfi9s"); - exchangeCodeTextbox.children[0].children[3].classList.remove("divider-1Jfi9s"); - }, 50); - - const usersList = { - idToName: this.epicIdToDisplayName, - nameToId: {} - } - for(const [id, username] of Object.entries(this.epicIdToDisplayName)) { - usersList.nameToId[username] = id; - } - const datalist = SettingsBuilder.createDatalist("epic", Object.keys(usersList.nameToId)); - const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, usersList, discordUserList, this.discordToEpicID, - "If you see numbers instead of names, it's either because the friends list hasn't loaded, or you are no longer friends with them.","Epic username", /^.+$/); - - const debugSwitch = SettingsBuilder.debugSwitch(this); - - return SettingsBuilder.settingsPanel(this, enabledSwitch, tokenTextbox, authCodeTextbox, exchangeCodeTextbox, userMapDiv, debugSwitch); - } -} - -/********** - ** EA ** - **********/ - -class EA extends Platform { - - constructor() { - super("ea"); - - this.eaLogoAssetId = "950210006509289492"; - this.apexRpcAppId = "893911040713191444"; // https://github.com/Holfz/ApexRPC - - this.apexAssetNames = { - "Logo": "apex-legends", - "Training": "firing-range", - "World's Edge": "world-edge", - "King's Canyon": "king-canyon", - "Olympus": "olympus", - "Stormpoint": "stormpoint", - "Encore": "encore" - } - - this.presenceCache = {}; - this.friendIdToDisplayName = {}; - this.assetCache = {}; - this.apexAssets = {}; - - this.loadData(); - if(this.enabled) { - this.start(); - } - } - - async start() { - if(!this.remid) return this.destroy(); - this.fetchApexAssets(); - const accessToken = await this.authenticate(this.remid); - await Promise.all([this.getOwnUsername(), this.getFriendsList()]); - if(accessToken) await this.connectWebsocket(accessToken); - } - - serializeData() { - return { - enabled: this.enabled || false, - remid: this.remid || "", - usersMap: this.discordToEaIDs || {}, - debug: this.debug || false - } - } - - deserializeData(data) { - this.enabled = data.enabled || false; - this.remid = (data.remid || "").trim(); - this.discordToEaIDs = data.usersMap || {}; - this.debug = data.debug || false; - } - - async fetchApexAssets() { - const req = await fetch(`https://discord.com/api/v9/oauth2/applications/${this.apexRpcAppId}/assets`); - const json = JSON.parse(req.body); - for(const asset of json) { - this.apexAssets[asset.name] = asset.id; - } - } - - async authenticate(remid) { - try { // step 1 - const req1 = await fetch("https://accounts.ea.com/connect/auth?" + - "client_id=JUNO_PC_CLIENT&" + - "response_type=code&" + - "nonce=1&" + - "pc_sign=eyJhdiI6InYxIiwiYnNuIjoiRzJDN002MyIsImdpZCI6Mzk4NzYsImhzbiI6IkFSUkFZMCIsIm1hYyI6IiQwMGZmOWJiNGMyYTAiLCJtaWQiOiI3NTc3MTg0MDY1MDM5NjIzNTkxIiwibXNuIjoiLkcyQzdNNjMuQ05DTUswMDA5NDAwMjYuIiwic3YiOiJ2MSIsInRzIjoiMjAyMi0yLTI3IDEzOjM1OjQyOjI0NiJ9.XPSVI2ksrbveN_FcG2ep_1QpqLphs-cWZRgcsSnIfsI&" + - "", { - headers: { - "Cookie": `remid=${remid}`, - } - }); - this.log(req1); - - if(!req1.headers['set-cookie']) { - BdApi.alert("Could not login to EA! Most likely your RemID is invalid."); - this.destroy(); - return; - } - - const remidHeader = req1.headers['set-cookie'][0]; - remid = remidHeader.split('=')[1].split(';')[0]; - this.log("New RemID: " + remid); - this.remid = remid; - this.saveData(); - - const redirectUrl = req1.headers.location; - const code = redirectUrl.match(/code=(.+)&?/)[1]; - this.log("Auth Code: " + code); - - // step 2 - const req2 = await fetch("https://accounts.ea.com/connect/token", { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded" - }, - body: - "grant_type=authorization_code" + - `&code=${code}&` + - "client_id=JUNO_PC_CLIENT&" + - "client_secret=4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te&" + - "" - }); - this.log(req2); - - const authResponse = JSON.parse(req2.body) - this.log(authResponse); - - authResponse.accessTokenExpires = Date.now() + authResponse.expires_in * 1000; - this.auth = authResponse; - return authResponse.access_token; - } catch(e) { - console.error(remid); - err(e); - } - } - - getRemIDFromEaApp() { - // I doubt this works on Mac - const filepath = process.env.LOCALAPPDATA + "/Electronic Arts/EA Desktop/cookie.ini"; - - let fileContents; - try { - fileContents = fs.readFileSync(filepath).toString(); - } catch(e) { - return [false, e.toString() || "Could not read file!"]; - } - - for(const line of fileContents.split('\n')) { - if(line.startsWith("remid=")) { - return [true, line.substring(26).trim()]; - } - } - - return [false, "Could not parse file! Is it corrupt? (Note: EA have started encrypting the cookie.ini file, so this plugin won't work until I find a way to bypass the encryption)"]; - } - - async getOwnUsername() { - this.log("Fetching own username..."); - - const req = await fetch("https://service-aggregation-layer.juno.ea.com/graphql?" + - "operationName=GetPersonas&" + - `variables=${encodeURIComponent(JSON.stringify({overrideCountryCode: ""}))}&` + - `extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: "b81297d11a35ee6e21f5c5582452121dccf7ef54fd60e66218ee7dd199fb310d"}}))}`, { - headers: { - "Authorization": `Bearer ${this.auth.access_token}`, - } - }); - const json = JSON.parse(req.body); - this.log(json); - - this.ownId = json.data.me.id; - this.log("My id is " + this.ownId); - - for(const persona of json.data.me.personas) { - if(persona.namespaceName === "cem_ea_id") - this.ownUsername = persona.displayName; - else if(persona.namespaceName === "discord" && persona.displayName) { - this.log(`My linked discord account is ${persona.displayName}`); - const [username, discriminator] = persona.displayName.split("#"); - if(username && discriminator) { - const user = ZeresPluginLibrary.DiscordModules.UserStore.findByTag(username, discriminator); - if(user && !(user.id in this.discordToEaIDs)) - this.discordToEaIDs[user.id] = [this.ownId]; - } - } - } - - if(this.ownUsername) { - this.log("My username is " + this.ownUsername); - this.friendIdToDisplayName[this.ownId] = this.ownUsername; - } else { - console.error("[EA] Couldn't find my username!"); - } - } - - async getFriendsList() { - this.log("Fetching friends list..."); - - const req = await fetch("https://service-aggregation-layer.juno.ea.com/graphql?" + - "operationName=GetMyFriends&" + - `variables=${encodeURIComponent(JSON.stringify({offset: 0, limit: 99}))}&` + - `extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: "f8856d3ce53eac1d88ee1780851888a25575fad4f142e7b6142c0ace28797baa"}}))}`, { - headers: { - "Authorization": `Bearer ${this.auth.access_token}`, - } - }); - const json = JSON.parse(req.body); - this.log(json); - - for(const friendData of json.data.me.friends.items) { - if(!friendData.player.uniqueName) continue; // ghost friend - this.friendIdToDisplayName[friendData.id] = friendData.player.displayName; - } - - this.log(this.friendIdToDisplayName); - } - - async connectWebsocket(accessToken) { - if(!this.enabled) return; - - const sock = new SimpleSocket("wss://rtm.tnt-ea.com:8095/websocket", { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) QtWebEngine/5.15.2 Chrome/83.0.4103.122 Safari/537.36 Origin/10.6.0.00000 EAApp/12.0.179.5090" - } - }); - this.socket = sock; - - sock.onconnect = () => { - this.log("Connected to WebSocket!"); - - clearInterval(this.reconnectInterval); - this.reconnectInterval = null; - - this.messageNumber = 0; - this.login(accessToken); - - this.heartbeat = setInterval(this.sendHeartbeat.bind(this), 10000); - } - sock.onmessage = this.messageReceived.bind(this); - sock.onclose = () => { - if(!this.enabled || sock !== this.socket) return this.log("Websocket disconnected!"); - console.error("EA disconnected! Retrying in 5 seconds..."); - - if(this.reconnectInterval) return; - - this.reconnectInterval = setInterval(async () => { - if(this.auth.accessTokenExpires - Date.now() < 10_000) { - accessToken = await this.authenticate(this.remid); - } - - this.log("Reconnecting..."); - this.connectWebsocket(accessToken); - }, 5000); - - clearTimeout(this.heartbeat); - } - } - - varInt(int) { - const bytes = []; - while (true) { - if ((int & ~0x7F) === 0) { - bytes.push(int); - return Buffer.from(bytes); - } - bytes.push((int & 0x7F) | 0x80); - int >>>= 7; - } - } - - readVarInt (buf, start=0) { - let value = 0; - let i = start; - let currentByte; - - do { - currentByte = buf[i]; - value |= ((currentByte & 0x7F) << (7 * (i - start))); - i++; - } while ((currentByte & 0x80) !== 0); - - return [value, i]; - } - - readString(buf, start) { - // start is the index of the string length, not the string itself - const [length, lengthEnd] = this.readVarInt(buf, start); - const subarray = buf.subarray(lengthEnd, lengthEnd + length); - return [subarray.toString(), lengthEnd + length]; - } - - stringifyBuffer = (buf, step=25) => { - const lines = []; - for(let i = 0; i < buf.length; i += step) { - let line = [...buf.slice(i, i + step)]; - let s = ""; - s += (line.map(n => n.toString(16).toUpperCase().padStart(2, '0')).join(' ')).padEnd(step * 3 - 1, ' '); - s += " | "; - s += line.map(n => 32 <= n && n <= 126 ? String.fromCharCode(n) : '.').join(''); - lines.push(s); - } - return lines.join('\n'); - } - - messageReceived(buf) { - this.log(" <-\n" + this.stringifyBuffer(buf)); - - try { // check if buffer has timestamp - const [bufferLength, bufferLengthEnd] = this.readVarInt(buf, 5); - if(buf[bufferLengthEnd] === 0x0A) { - // there is a timestamp - const [timestampString, timestampStringEnd] = this.readString(buf, bufferLengthEnd + 1); - const timestamp = timestampString.split('-')[2]; - - const packetTypeIndex = timestampStringEnd; - const packetType = buf[packetTypeIndex]; - switch (packetType) { - case 0x7A: - // Auth response - this.log("Got auth response!"); - - const [messageLength1, messageLength1End] = this.readVarInt(buf, packetTypeIndex + 1); - const [messageLength2, messageLength2End] = this.readVarInt(buf, messageLength1End + 1); - - const [ownIdString, ownIdEnd] = this.readString(buf, messageLength2End + 1); - this.ownId = ownIdString.split(':')[1]; - this.log("My id is " + this.ownId); - - this.requestOwnPresence(); - this.requestFriendPresences(); - break; - case 0x92: - // friends list - this.log("Got friends list!"); - break; - case 0x62: - // friend name - this.log("Got friend name!"); - break; - } - } else if(buf[bufferLengthEnd] === 0x3A) { - // there is no timestamp - const [bufferLengthAgain, bufferLengthAgainEnd] = this.readVarInt(buf, bufferLengthEnd + 1); - const [userId, userIdEnd] = this.readString(buf, bufferLengthAgainEnd + 1); - - const packetTypeIndex = userIdEnd; - const packetType = buf[packetTypeIndex]; - switch (packetType) { - case 0x12: - // presence - if(userId === this.ownId) this.log("Received my own presence"); - else this.log("Received presence for user " + userId); - - const [presenceJson, presenceJsonEnd] = this.readString(buf, packetTypeIndex + 1); - const presence = JSON.parse(presenceJson); - this.log(presence); - - if(presence.gameActivity_richPresence) this.log(JSON.parse(presence.gameActivity_richPresence)); - - const [timestamp, timestampEnd] = this.readString(buf, presenceJsonEnd + 1); - - this.processPresence(userId, presence, +new Date(timestamp)); // does this work with timezones? - - break; - case 0x28: - // no presence for user - this.log(`User ${userId} is offline`); - - delete this.presenceCache[userId]; - break; - } - } else { - if( - buf[bufferLengthEnd] !== 0xC2 && // "session changed" - buf[bufferLengthEnd] !== 0x82 && // game invite - buf[bufferLengthEnd] !== 0x72 // unknown, short buffer e.g. "00 00 00 04 0A 02 72 00" - ) { - if(this.debug) err("Neither 0A or 3A after message! " + buf[bufferLengthEnd].toString(16)); - } - } - } catch(e) { - err(e); - } - } - - send(buf, log=true) { - if(log) this.log(" ->\n" + this.stringifyBuffer(buf)); - this.socket.send(buf); - } - - encodeString(s) { - const stringLength = this.varInt(s.length); - const stringEncoded = Buffer.from(s, 'utf-8'); - return Buffer.concat([stringLength, stringEncoded]); - } - - createTimestampBuffer() { - const timestamp = Date.now(); - const timestampString = `c-${this.messageNumber++}-${timestamp}-${timestamp}`; - return this.encodeString(timestampString); - } - - formatAndSendBuffer(buf, log=true) { - try { - const timestampBuffer = this.createTimestampBuffer(); - - const totalLength = timestampBuffer.length + buf.length; - const totalLengthVarInt = this.varInt(totalLength + 1); - const totalLength32Bit = Buffer.allocUnsafe(4); - totalLength32Bit.writeUint32BE(totalLength + totalLengthVarInt.length + 2); - - const newline = Buffer.from([0x0A]); - const header = Buffer.concat([totalLength32Bit, newline, totalLengthVarInt, newline]); - - this.send(Buffer.concat([header, timestampBuffer, buf]), log); - } catch(e) { - err(e); - } - } - - login(accessToken) { - try { - const packetType = Buffer.from([0xF2, 0x02]); - - const newline = Buffer.from([0x0A]); - const accessTokenBuffer = this.encodeString(accessToken); - const middleData = Buffer.from([0x10, 0x00, 0x18, 0x00, 0x20, 0x00, 0x2A, 0x06, 0x6F, 0x72, 0x69, 0x67, 0x69, 0x6E, 0x30, 0x04, 0x3A]); - const platformJson = this.encodeString('{"clientType":"ClientWeb","version":"juno-spa-0.0.1-15146-80d8a20","integrations":"LoginV3"}'); - - const restOfPacketLengthBuffer = this.varInt(newline.length + accessTokenBuffer.length + middleData.length + platformJson.length); - - const handshakeBuffer = Buffer.concat([packetType, restOfPacketLengthBuffer, newline, accessTokenBuffer, middleData, platformJson]); - this.formatAndSendBuffer(handshakeBuffer); - } catch(e) { - err(e); - } - } - - sendHeartbeat() { - try { // the function is called in setInterval, so any unhandled error will crash the whole discord - this.formatAndSendBuffer(Buffer.from([0xA2, 0x01, 0x00]), false); - } catch(e) { - err(e); - } - } - - requestOwnPresence() { - const header = Buffer.from([0x12, 0x19, 0x12, 0x17, 0x0A]); - const ownIdBuffer = this.encodeString(this.ownId); - const middleNumber = Buffer.from([0x12]); - const originString = this.encodeString("origin"); - this.formatAndSendBuffer(Buffer.concat([header, ownIdBuffer, middleNumber, originString])); - } - - requestFriendPresences() { - this.formatAndSendBuffer(Buffer.from([0x9A, 0x03, 0x00])); - } - - async processPresence(id, data, timestamp) { - try { - if(data.gameActivity_isNull) return this.deletePresence(id); - if(data.presence_null === undefined) return; // is own status (not presence, just online/away/invisible) - - const presenceData = JSON.parse(data.gameActivity_gamePresence); - const richPresenceData = data.gameActivity_richPresence ? JSON.parse(data.gameActivity_richPresence) : {}; - const gameArtUrl = await this.getGameArt(data.gameActivity_productId) - const presence = { - application_id: customRpcAppId, - name: data.gameActivity_gameTitle, - details: richPresenceData.data, - type: 0, - timestamps: {start: timestamp}, - assets: { - large_image: gameArtUrl ? `url:${gameArtUrl}` : this.eaLogoAssetId, - large_text: data.gameActivity_gameTitle - }, - username: this.friendIdToDisplayName[id], - priority: Priorities.PLAYING, - session: presenceData.data.session - }; - - const previousPresence = this.presenceCache[id]; - const wasPlayingSameGame = previousPresence && previousPresence.name === presence.name; - const status = richPresenceData.data; - - switch (data.gameActivity_productId) { - case "Origin.OFR.50.0002694": { // Apex Legends - presence.application_id = this.apexRpcAppId; - presence.assets.large_image = this.apexAssets[this.apexAssetNames["Logo"]]; - - let match = status.match(/(.+) - (.+)/); - if(match) { - const map = match[1]; - presence.assets.large_image = this.apexAssets[this.apexAssetNames[map]] || `url:${gameArtUrl}`; - - presence.details = "Playing " + match[2]; // gamemode - match = status.match(/(.+) - (.+) \((.+)\)/); - if(match) { - presence.state = match[3]; // "10 Squads Left" - } - } - - if(wasPlayingSameGame && presence.session === previousPresence.session) { - presence.timestamps = previousPresence.timestamps; - } - break; - } - case "Origin.OFR.50.0004567": { // FIFA 22 - let match = status.match(/^\((.+)\)$/); - if(match) presence.details = match[1]; - - match = status.match(/^(.+) (\d+-\d+ [A-Za-z]{1,3} [V\-] [A-Za-z]{1,3}.+$)/); - if(match) { - presence.details = "Playing " + match[1]; - presence.state = match[2]; - - if(wasPlayingSameGame && previousPresence.state) { - presence.timestamps = previousPresence.timestamps; - } - } - - match = status.match(/^(.+) \((.+)\)$/); - if(match) { - presence.details = match[1]; - presence.state = match[2]; - presence.priority = Priorities.IN_LOBBY; - } - break; - } - default: { - if(previousPresence && status === previousPresence.details) { - presence.timestamps = previousPresence.timestamps; - } - } - } - - this.presenceCache[id] = presence; - this.log(presence); - - if(!previousPresence || presence.name !== previousPresence.name) { - this.updateUser(id, this.discordToEaIDs); - } - } catch(e) { - console.error(id, data, timestamp); - err(e); - } - } - - deletePresence(id) { - if(this.presenceCache[id]) { - delete this.presenceCache[id]; - this.updateUser(id, this.discordToEaIDs); - } - } - - async getGameArt(productId) { - try { - if(this.assetCache[productId]) return this.assetCache[productId]; - - const req = await fetch("https://service-aggregation-layer.juno.ea.com/graphql?" + - "operationName=inGamePresenceData&" + - `variables=${encodeURIComponent(JSON.stringify({locale: "en", offerIds: [productId]}))}&` + - `extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: "6d7316368c350bbf3de50676add3d1d00d73021270f9d365e5a5e388fd9741c8"}}))}`); - const json = await JSON.parse(req.body); - - if(!json.data.gameProducts.items[0].baseItem) return null; - - this.assetCache[productId] = json.data.gameProducts.items[0].baseItem.keyArt.aspect1x1Image.path; - return json.data.gameProducts.items[0].baseItem.keyArt.aspect1x1Image.path; - } catch(e) { - console.error(e); - err("Could not get game art for game " + productId); - } - } - - getPresence(discord_id) { - return super.getPresence(discord_id, this.discordToEaIDs, this.presenceCache); - } - - getSettings(discordUserList, discordUsersDatalist) { - // enabled switch - const enabledSwitch = SettingsBuilder.enabledSwitch(this); - - // cookies textbox - const textboxChange = (value) => { - this.cookies = value; - if(this.enabled) { - SettingsBuilder.toggleEnabledSwitch(enabledSwitch); - } - } - const buttonClick = () => { - BdApi.showConfirmationModal("Warning", "Fetching your RemID will log you out of the EA App. Are you sure?", { - danger: true, - confirmText: "Yes, I'm sure", - onConfirm: getRemID - }); - } - const getRemID = () => { - const button = remidTextbox.children[0].children[1].children[1]; - button.classList.remove("bd-button-danger"); - button.innerHTML = "Fetching..."; - - const [success, remid] = this.getRemIDFromEaApp(); - if(success) { - this.remid = remid; - remidTextbox.children[0].children[1].children[0].value = remid; - button.innerHTML = "Success!"; - this.saveData(); - } else { - const error = remid; - button.classList.add("bd-button-danger"); - if(error.message && error.message.includes("no such file")) { - button.innerHTML = "File not found"; - } else { - button.innerHTML = "Failed"; - console.error(error); - BdApi.alert(error); - } - } - } - const remidTextbox = SettingsBuilder.textboxWithButton("RemID Cookie", "Your EA RemID cookie. Fetching it only works on the new 'EA App', not Origin.", - this.remid, textboxChange, {}, "Fetch RemID from EA App", buttonClick); - - const usersList = { - idToName: this.friendIdToDisplayName, - nameToId: {} - } - for(const [id, username] of Object.entries(this.friendIdToDisplayName)) { - usersList.nameToId[username] = id; - } - const datalist = SettingsBuilder.createDatalist("ea", Object.keys(usersList.nameToId)); - const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, usersList, discordUserList, this.discordToEaIDs, null, "EA Username", /^\d+$/); - - const debugSwitch = SettingsBuilder.debugSwitch(this); - - return SettingsBuilder.settingsPanel(this, enabledSwitch, remidTextbox, userMapDiv, debugSwitch); - } - - destroy(pluginShutdown) { - this.enabled = false; - for(const id in this.presenceCache) this.deletePresence(id); - clearInterval(this.heartbeat); - clearInterval(this.reconnectInterval); - if(this.socket) this.socket.close(1001); - if(!pluginShutdown) this.saveData(); - } -} - - - -/************** - ** PLUGIN ** - **************/ - -const platforms = [Riot, Epic, Steam, EA, Minecraft, Twitch]; - -const CrossPlatformPlaying = (() => { - return !global.ZeresPluginLibrary ? class { - constructor() {this._config = config;} - getName() {return config.info.name;} - getAuthor() {return config.info.authors.map(a => a.name).join(", ");} - getDescription() {return config.info.description;} - getVersion() {return config.info.version;} - - load() { - BdApi.showConfirmationModal("Library Missing", `The library plugin needed for ${config.info.name} is missing. Please click Download Now to install it.`, { - confirmText: "Download Now", - cancelText: "Cancel", - onConfirm: () => { - require("request").get("https://rauenzi.github.io/BDPluginLibrary/release/0PluginLibrary.plugin.js", async (error, response, body) => { - if (error) return require("electron").shell.openExternal("https://betterdiscord.net/ghdl?url=https://raw.githubusercontent.com/rauenzi/BDPluginLibrary/master/release/0PluginLibrary.plugin.js"); - await new Promise(r => fs.writeFile(require("path").join(BdApi.Plugins.folder, "0PluginLibrary.plugin.js"), body, () => { - BdApi.alert("Successfully installed CrossPlatformPlaying! Press Ctrl+R to reload Discord, then go to Settings > Plugins to enable and configure the plugin."); - r(); - })); - }); - } - }); - } - start() {} - stop() {} - } : (([Plugin, Api]) => { - const plugin = (Plugin, Library) => { - return class CrossPlatformPlaying extends Plugin { - constructor() { - super(); - } - - onStart() { - // Required function. Called when the plugin is activated (including after reloads) - - console.log("[CrossPlatformPlaying] Starting..."); - const loadStart = Date.now(); - - // for debugging - global.CPP = { - plugin: this, platforms, config, - fetch, setTimeout, setInterval, updateUser, removeFromList, - Platform, SimpleSocket, SettingsBuilder, Priorities, - timeouts, intervals - }; - - // check if config json is valid - try { - BdApi.loadData(pluginName, ""); - } catch(e) { - return console.error("[CrossPlatformPlaying] Could not load config JSON! Is it corrupt?"); - } - - discord_id = ZeresPluginLibrary.DiscordModules.UserInfoStore.getId(); - - this.loadPatches(); - this.patchGetActivity(); - this.patchActivityHeader(); - this.patchGetAssetImage(); - this.reloadActivityRenderModule(); - this.patchRenderHeader(); - this.setUpdateUser(); - - // initialise platforms - this.instances = []; - for(const platform of platforms) { - this.instances.push(new platform()); - } - - console.log(`[CrossPlatformPlaying] Started in ${Date.now() - loadStart}ms`); - } - - onStop() { - // Required function. Called when the plugin is deactivated - BdApi.Patcher.unpatchAll(pluginName); - - for(const platform of this.instances) platform.destroy(true); - this.instances.length = 0; // clear array for next time plugin is launched - - for(const timeout of timeouts) clearTimeout(timeout); - for(const interval of intervals) clearInterval(interval); - timeouts.length = 0; intervals.length = 0; - } - - getSettingsPanel() { - if(!this.buttonClassNames) this.buttonClassNames = BdApi.findModuleByProps("button", "lookFilled", "grow"); - - const body = document.createElement("div"); - - // to check if settings panel is still open - const bodyId = crypto.randomBytes(16).toString("base64"); - body.id = bodyId; - - const theInterval = setInterval(() => { - if(document.getElementById(bodyId)) { - for(let i = 0; i < buttons.length; i++) { - const platform = this.instances[i]; - const button = buttons[i]; - - if(platform.enabled) { - button.classList.remove(this.buttonClassNames.colorTransparent); - button.classList.add(this.buttonClassNames.colorBrand); - } else { - button.classList.remove(this.buttonClassNames.colorBrand); - button.classList.add(this.buttonClassNames.colorTransparent); - } - - } - } else { // settings panel closed - clearInterval(theInterval); - } - }, 100); - - const buttons = []; - const panels = []; - let activePanel; - - // insert formatted list of all discord users - const discordUserList = { - idToName: {}, - nameToId: {} - } - ZeresPluginLibrary.DiscordModules.UserStore.forEach(user => { - const name = user.username + '#' + user.discriminator; - discordUserList.idToName[user.id] = name; - discordUserList.nameToId[name] = user.id; - }); - const discordUsersDatalist = SettingsBuilder.createDatalist("discordUserList", Object.keys(discordUserList.nameToId)); - body.append(discordUsersDatalist); - - // create a panel and button for each platform - for(const platform of this.instances) { - let platformIndex = buttons.length; - - // the different panels for different platforms - const panel = document.createElement("div"); - panel.id = "panel-" + platform.platformId; - panel.loaded = false; - panel.style.display = "none"; // hide the panel initially - panels.push(panel); - - // the buttons clicked to show the different platforms - const button = document.createElement("button"); - button.classList.add(this.buttonClassNames.button, this.buttonClassNames.lookFilled, this.buttonClassNames.colorBrand, this.buttonClassNames.sizeMedium, this.buttonClassNames.grow); - button.style.margin = "5px"; - button.style.display = "inline"; // to make the buttons appear side by side - button.innerText = platform.constructor.name; - button.onclick = () => { - if(activePanel) activePanel.style.display = "none"; - if(this.activePanelIndex !== undefined) { - buttons[this.activePanelIndex].disabled = false; - buttons[this.activePanelIndex].classList.remove("bd-button-disabled"); - } - - activePanel = panel; - this.activePanelIndex = platformIndex; - - if(!panel.loaded) { - panel.append(platform.getSettings(discordUserList, discordUsersDatalist)); - panel.loaded = true; - } - - panel.style.display = "block"; - button.disabled = true; - button.classList.add("bd-button-disabled"); - } - - buttons.push(button); - } - - // start inserting the elements - const buttonsDiv = document.createElement("div"); - buttonsDiv.id = "platform-buttons"; - buttonsDiv.style.margin = "10px"; - body.append(buttonsDiv); - - for(const button of buttons) { - buttonsDiv.append(button); - } - - const panelsDiv = document.createElement("div"); - panelsDiv.id = "platform-panels"; - panelsDiv.style.padding = "10px"; - body.append(panelsDiv); - - for(const panel of panels) { - panelsDiv.append(panel); - } - - buttons[this.activePanelIndex || 0].click(); - - return body; - } - - loadPatches() { - // discord webpack module helper functions - const moduleList = Object.values(ZeresPluginLibrary.WebpackModules.getAllModules()); - - const stringsModule = moduleList.find(m => m.exports.default && m.exports.default.Messages && m.exports.default.Messages.USER_ACTIVITY_HEADER_PLAYING); - - const activityRenderModule = moduleList.find(m => m.exports.default && m.exports.default.prototype && m.exports.default.prototype.renderImage && m.exports.default.prototype.renderHeader); - const activityAssetModule = moduleList.find(m => m.exports.getAssetImage); - const activityHeaderModule = moduleList.find(m => m.exports.default && m.exports.default.toString().includes("default.Messages.USER_ACTIVITY_HEADER_PLAYING")); - - const ActivityStore = ZeresPluginLibrary.DiscordModules.UserStatusStore; - - this.patchGetActivity = () => { - BdApi.Patcher.after(pluginName, ActivityStore, "getActivities", (_this, args, ret) => { - const start = Date.now(); - - try { - const id = args[0]; - - let newActivities = []; - - for(const platform of this.instances) { - const presences = platform.getPresence(id); - if(presences && presences.length) newActivities.push(...presences); - } - - if(newActivities.length === 0) return ret; - - const activityPriority = (act) => { - if(act.priority) return act.priority; - if(act.type === 2) return Priorities.SECONDARY; // Spotify - if(act.application_id === "307998818547531777") return Priorities.SECONDARY; // Medal.tv - if(act.details || act.state) return Priorities.DISCORD_RICH_PRESENCE; - return Priorities.DISCORD_NORMAL; - } - const sortFunction = (a, b) => { - return activityPriority(b) - activityPriority(a); - } - return newActivities.concat(ret).sort(sortFunction); - } finally { - const time = Date.now() - start; - if(time > 5) console.warn(`[${pluginName}] getActivities took ${time}ms`); - } - }); - - global.CPP.getActivities = ZeresPluginLibrary.DiscordModules.UserStatusStore.getActivities; - } - - this.setUpdateUser = () => { - const dispatchFunction = ZeresPluginLibrary.DiscordModules.UserStatusStore._dispatcher.dispatch.bind(ZLibrary.DiscordModules.UserStatusStore._dispatcher); - - updateUser = (id) => { - const user = ZeresPluginLibrary.DiscordModules.UserStore.getUser(id); - /*if(user) console.log(`Updating user ${user.username}#${user.discriminator}`); - else console.log(`Updating user with id ${id}...`);*/ - - if(user) dispatchFunction({ - type: "GUILD_MEMBER_UPDATE", - guildId: null, // update in all guilds - user: user - }); - } - global.CPP.updateUser = updateUser; - } - - this.patchActivityHeader = () => { - // only works with "playing ..." headers - BdApi.Patcher.after(pluginName, activityHeaderModule.exports, "default", (_this, args, ret) => { - if(!args[0].username) return ret; - const presence = args[0]; - - if(ret === stringsModule.exports.default.Messages.USER_ACTIVITY_HEADER_PLAYING) - return "Playing as " + presence.username; - - // playing on xbox -> playing on xbox as urmom420 - return ret + " as " + presence.username; - }); - } - - this.patchGetAssetImage = () => { - // setup getAssetImage patch - BdApi.Patcher.instead(pluginName, activityAssetModule.exports, "getAssetImage", (_this, args, func) => { - if(args[1] && args[1].startsWith("url:")) return args[1].substr(4); - return func(...args); - }); - } - - this.patchRenderHeader = () => { - BdApi.Patcher.after(pluginName, activityRenderModule.exports.default.prototype, "renderHeader", (_this, args, ret) => { - if(!ret || !_this.activity || !_this.activity.username) return ret; - - if(!ret.props.children[1].props.children.props.children.startsWith("Playing as")) - ret.props.children[1].props.children.props.children += " as " + _this.activity.username; - - return ret; - }); - } - - this.reloadActivityRenderModule = () => { - // the activityRenderModule.renderImage function stores a reference to the unpatched getAssetImage function - // we need to reload the webpack to update the reference - const reloadedModule = {exports: {}}; - ZeresPluginLibrary.WebpackModules.require.m[activityRenderModule.id](reloadedModule, reloadedModule.exports, ZeresPluginLibrary.WebpackModules.require); - - BdApi.Patcher.instead(pluginName, activityRenderModule.exports.default.prototype, "renderImage", (_this, args) => reloadedModule.exports.default.prototype.renderImage.call(_this, ...args)); - BdApi.Patcher.instead(pluginName, activityRenderModule.exports.default.prototype, "renderHeader", (_this, args) => reloadedModule.exports.default.prototype.renderHeader.call(_this, ...args)); - } - } - }; - }; - return plugin(Plugin, Api); - })(global.ZeresPluginLibrary.buildPlugin(config)); -})(); -/*@end@*/ + const fs = require("fs"); + const crypto = require("crypto"); + const preload = global.CPP_preload; + + // send an HTTP request to a URL, bypassing CORS policy + const fetch = preload.https.fetch; + + // basic error handling + const err = e => { + console.error(e); + for(const errCode of ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ENOENT", "ECONNABORTED"]) { + if(e.code === errCode || e.errno === errCode) return; // steam & hypixel sometimes time out for no reason + } + debugger; + BdApi.alert("Error happened!\n" + e); + } + + // convert a Buffer (UInt8Array) to a utf-8 string + const textDecoder = new TextDecoder(); + const bufToString = (buffer) => textDecoder.decode(buffer); + + // format an application asset URL to properly render, without needing to patch getAssetImage + // this takes advantage of the fact you can pass a discord media proxy url as an asset image using "mp:" + // as well as properties of the URL() constructor to tell it not to prepend the media proxy hostname + // while bypassing the split at the : character that discord does + const formatAsset = (url) => { + if(!url) return null; + const urlObject = new URL(url); + return `mp:${urlObject.toString().substring(urlObject.protocol.length)}`; + } + + const pluginName = "CrossPlatformPlaying"; + const customRpcAppId = "883483733875892264"; + + const config = { + "info": { + "name": pluginName, + "authors": [{ + "name": "Giorgio", + "discord_id": "316978243716775947", + "github_username": "giorgi-o" + }], + "version": "0.2.8", + "description": "Lets you see what your friends are playing even if they turned off game activity", + "github": "https://github.com/giorgi-o/CrossPlatformPlaying", + "github_raw": "https://raw.githubusercontent.com/giorgi-o/CrossPlatformPlaying/main/CrossPlatformPlaying.plugin.js" + }, + "changelog": [ // added: green, improved: blurple, fixed: red, progress: yellow + { + "title": "Beta testing", + "type": "added", + "items": [ + "If you see this, it's because you installed the beta version of the plugin. Welcome :)", + "This version is probably very buggy, but at least it's better than nothing'.", + "If you encounter any bugs, don't hesitate to tell me! I'm not going to fix a bug if no one tells me about it." + ] + }, + ] + }; + + // the discord id of the current user (once the plugin loads) + let discord_id = 0; + + // update the user's status in the guild member list + // call this when the user changes game (not just the game state) + let updateUser = (id) => {}; + + + // to implement a new platform, create a subclass of Platform + // and override constructor(), start(), serializeData(), deserializeData(), getPresence(), destroy() and getSettings() + class Platform { + // all platforms should call super() with their platformId + constructor(platformId) { + this.platformId = platformId; // used when storing the platform settings + } + + // should be called in constructor if the platform is enabled + start() {} + + // loads the plugin settings and calls deserializeData(). Should be called in constructor before start() + loadData() { + const data = BdApi.loadData(pluginName, this.platformId); + this.deserializeData(data || {}); + this.saveData(); + } + + // save the data from serializeData() on disk + saveData() { + BdApi.saveData(pluginName, this.platformId, this.serializeData()); + } + + // returns a JSON serializable object containing the data to be saved on disk + serializeData() {}; + + // takes the JSON stored on disk and deserializes it to be used by the platform + deserializeData(data) {}; + + // helper method that can be used for simple platforms + // platforms should implement getPresence with only one argument, discord_id + getPresence(discord_id, discordToPlatformId, presenceCache) { + if(!discord_id || !presenceCache || !discordToPlatformId || !discordToPlatformId[discord_id]) return; + + const presences = []; + for (const platform_id of discordToPlatformId[discord_id]) { + if(presenceCache[platform_id]) + presences.push(presenceCache[platform_id]); + } + if(presences.length) return presences; + } + + // helper method to call updateUser() on a user using their user id + // because typically the processPresence() functions only have the platform id, not discord id + updateUser(platformId, discordToPlatformId) { + const discordIds = []; + for(const [discordId, platformIds] of Object.entries(discordToPlatformId)) { + let matches; + if(Array.isArray(platformIds)) matches = platformIds.includes(platformId); + else matches = platformIds === platformId; + + if(matches) discordIds.push(discordId); + } + + for(const discordId of discordIds) + updateUser(discordId); + } + + // called when the plugin is stopped or the platform is disabled + // pluginShutdown is true if the whole plugin is being disabled + destroy(pluginShutdown) {}; + + // helper function to restart the platform, for example to re-authenticate. + restart() { + this.destroy(false); + this.enabled = true; + this.saveData(); + this.start(); + } + + // should return an HTML element containing the settings panel + // takes as argument an object containing a map of discord IDs to and from usernames + getSettings() { + const div = document.createElement("div"); + div.innerText = "No settings panel for " + this.platformId; + return div; + } + + log(s) { + if(!this.debug) return; + if(typeof s === "object") console.log(`[${this.platformId.toUpperCase()}]`, s); + else console.log(`[${this.platformId.toUpperCase()}] ${s}`); + } + } + + const removeFromList = (list, value) => { + const index = list.indexOf(value); + if(index !== -1) list.splice(index, 1); + } + + const timeouts = []; + const intervals = []; + + const setTimeout = (fn, delay) => { + const id = window.setTimeout(() => { + removeFromList(timeouts, id); + try {fn()} + catch(e) {err(e)} + }, delay); + timeouts.push(id); + return id; + } + const setInterval = (fn, delay) => { + const id = window.setInterval(() => { + try {fn()} + catch(e) {err(e)} + }, delay); + intervals.push(id); + return id; + } + + + // custom websocket client adding support for HTTP headers and cookies + const SimpleSocket = { + create: preload.https.simpleSocket, + send: preload.https.sendToSocket, + close: preload.https.closeSocket + } + + const Priorities = { + PLAYING: 7, // user has game open & actively playing + IN_LOBBY: 6, // user has game open and is about to launch (in lobby) + DISCORD_RICH_PRESENCE: 5, // discord presence, with rich presence + IN_LOBBY_AFK: 4, // user has game open but is afk + NONPRIMARY_PLAYING: 3, // user has their game open, but this is not the primary presence (e.g. Steam) + DISCORD_NORMAL: 2, // discord presence, without rich presence + SECONDARY: 1 // secondary activity (e.g. Spotify/Twitch) + } + + // helper functions for building the settings panel + const SettingsBuilder = { + enabledSwitch: (platform) => { + const onChange = (value) => { + const wasEnabled = platform.enabled; + platform.enabled = value; + if(!wasEnabled && value) platform.start(); + if(wasEnabled && !value) platform.destroy(); + platform.saveData(); + } + return new ZeresPluginLibrary.Settings.Switch("Enabled", "Whether this platform is enabled", platform.enabled, onChange); + }, + toggleEnabledSwitch: (enabledSwitch) => { + enabledSwitch.getElement().children[0].children[0].children[1].children[0].children[1].click(); + }, + debugSwitch: (platform) => { + const onChange = (value) => { + platform.debug = value; + if(value) platform.log("Debug enabled!"); + } + return new ZeresPluginLibrary.Settings.Switch("Debug", "Whether to print debug info to the console", platform.debug, onChange); + }, + getTextboxInput: (textbox) => { + return textbox.children[0].children[1].children[0]; + }, + textboxWithButton: (name, note, value, onChange, textboxOptions, buttonText, onClick, timeout=50) => { + if(!name) name = ""; // if name is null, button formatting doesn't work for some reason + const textbox = new ZeresPluginLibrary.Settings.Textbox(name, note, value, onChange, textboxOptions).getElement(); + setTimeout(() => { + const button = document.createElement("button"); + button.innerText = buttonText; + button.onclick = onClick; + + button.classList.add("bd-button"); + button.style.fontSize = "16px"; + button.style.marginLeft = "10px"; + button.style.whiteSpace = "nowrap"; + + const div = textbox.children[0].children[1]; + + div.style.flexDirection = "row"; + + div.append(button); + }, timeout); + return textbox; + }, + settingsPanel: (platform, ...nodes) => { + const panel = new ZeresPluginLibrary.Settings.SettingPanel(() => platform.saveData(), ...nodes); + return panel.getElement(); + }, + createDatalist: (id, values) => { + const datalist = document.createElement("datalist"); + datalist.id = id; + for(const value of values) { + const option = document.createElement("option"); + option.value = value; + datalist.append(option); + } + return datalist; + }, + userMapInterface: (platform, platformDatalist, discordDatalist, platformUserList, discordUserList, usersMap, description, platformHeaderValue, platformIdRegex=/./, discordIdRegex=/^\d{15,}$/) => { + /** userList format: { + * idToName: { + * 1234: "gary" + * }, + * nameToId: { + * "gary": 1234 + * } + * } + */ + + // get a few class names from discord (can't find them in ZLibrary) + if(!SettingsBuilder.inputClassNames) SettingsBuilder.inputClassNames = BdApi.findModuleByProps("input", "inputMini", "inputWrapper"); + if(!SettingsBuilder.descriptionClassNames) SettingsBuilder.descriptionClassNames = BdApi.findModuleByProps('labelBold', 'labelDescriptor', 'labelSelected'); + + const userMapDiv = document.createElement("div"); + userMapDiv.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.container); + if(platformDatalist) userMapDiv.append(platformDatalist); + + if(description) { + const descriptionDiv = document.createElement("div"); + descriptionDiv.className = (SettingsBuilder.descriptionClassNames.description); + descriptionDiv.innerHTML = description; + descriptionDiv.style.marginBottom = "6px"; + userMapDiv.append(descriptionDiv); + } + + const table = document.createElement("table"); + table.style.width = "100%"; + userMapDiv.append(table); + + // top row with + button and labels + const topRow = document.createElement("tr"); + topRow.id = platform.platformId + "-row-top"; + + const addRowButton = document.createElement("button"); + addRowButton.innerText = "+"; + addRowButton.className = "bd-button"; + const addRowButtonColumn = document.createElement("th"); + addRowButtonColumn.append(addRowButton); + topRow.append(addRowButtonColumn); + + const platformColumnTitle = document.createElement("th"); + platformColumnTitle.innerText = platformHeaderValue || "Platform user"; + platformColumnTitle.style.color = "var(--header-primary)"; + topRow.append(platformColumnTitle); + + const discordColumnTitle = document.createElement("th"); + discordColumnTitle.innerText = "Discord user"; + discordColumnTitle.style.color = "var(--header-primary)"; + topRow.append(discordColumnTitle); + + table.append(topRow); + + // handle saving data to json + let saveTimeout; + const saveData = () => { + clearTimeout(saveTimeout); + + // delete all entries in old usersMap + for(const user of Object.keys(usersMap)) delete usersMap[user]; + + for(const row of table.children) { + if(row.id === platform.platformId + "-row-top") continue; + + const [, platformColumn, discordColumn] = row.children; + const platformInput = platformColumn.children[0]; + const discordInput = discordColumn.children[0]; + + let platformValue = platformInput.value; + let discordValue = discordInput.value; + + if(platformUserList && platformUserList.nameToId[platformValue]) { + platformValue = platformUserList.nameToId[platformValue]; + } else if(platformIdRegex && !platformIdRegex.test(platformValue)) { + platformInput.style.color = "red"; + continue; + } + platformInput.style.color = null; + + if(discordUserList && discordUserList.nameToId[discordValue]) { + discordValue = discordUserList.nameToId[discordValue]; + } else if(discordIdRegex && !discordIdRegex.test(discordValue)) { + discordInput.style.color = "red"; + continue; + } + discordInput.style.color = null; + + if(!platformValue || !discordValue) continue; + + if(Array.isArray(usersMap[discordValue])) { + usersMap[discordValue].push(platformValue); + } else { + usersMap[discordValue] = [platformValue]; + } + } + + saveTimeout = setTimeout(() => { + platform.saveData(); + }, 500); + } + + let id = 0; + + const addRow = (platformValue, discordValue, insertAtEnd=false) => { + const row = document.createElement("tr"); + row.style.width = "100%"; + row.id = platform.platformId + "-row-" + id.toString(); + + // X button + const removeButton = document.createElement("button"); + removeButton.className = "bd-button"; + removeButton.innerText = "X"; + removeButton.onclick = () => removeRow(row.id); + + const removeButtonColumn = document.createElement("th"); + removeButtonColumn.append(removeButton); + row.append(removeButtonColumn); + + // platform dropdown + const platformInput = document.createElement("input"); + platformInput.className = SettingsBuilder.inputClassNames.input; + platformInput.style.width = "100%"; + platformInput.oninput = saveData; + if(platformValue) platformInput.value = platformValue; + if(platformDatalist) platformInput.setAttribute("list", platformDatalist.id); + + const platformInputColumn = document.createElement("th"); + platformInputColumn.style.width = "50%"; + platformInputColumn.append(platformInput); + row.append(platformInputColumn); + + // discord dropdown + const discordInput = document.createElement("input"); + discordInput.className = SettingsBuilder.inputClassNames.input; + discordInput.style.width = "100%"; + discordInput.oninput = saveData; + if(discordValue) discordInput.value = discordValue; + if(discordDatalist) discordInput.setAttribute("list", discordDatalist.id); + + const discordInputColumn = document.createElement("th"); + discordInputColumn.style.width = "50%"; + discordInputColumn.append(discordInput); + row.append(discordInputColumn); + + if(insertAtEnd) table.append(row); + else table.insertBefore(row, table.children[1]); + + id++; + } + addRowButton.onclick = () => addRow(); + + const removeRow = (id) => { + table.removeChild(document.getElementById(id)); + if(table.children.length === 1) addRow(); + saveData(); + } + + const userCount = Object.values(usersMap).flat().length; + if(userCount === 0) { + addRow("", ""); + } + else if(userCount === 1) { + const [[discord_id, platform_ids]] = Object.entries(usersMap); + addRow(platform_ids[0], discord_id); + } + else { + for(const [discord_id, platform_ids] of Object.entries(usersMap)) { + for(const platform_id of platform_ids) { + addRow(platformUserList && platformUserList.idToName[platform_id] || platform_id, + discordUserList && discordUserList.idToName[discord_id] || discord_id, true); + } + } + } + + // add divider + const divider = document.createElement("div"); + divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.divider); + divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.dividerDefault); + userMapDiv.append(divider); + + return userMapDiv; + }, + list: (platform, theList, description, header, regex=/./) => { + // get a few class names from discord (can't find them in ZLibrary) + if(!SettingsBuilder.inputClassNames) SettingsBuilder.inputClassNames = BdApi.findModuleByProps("input", "inputMini", "inputWrapper"); + if(!SettingsBuilder.descriptionClassNames) SettingsBuilder.descriptionClassNames = BdApi.findModuleByProps('labelBold', 'labelDescriptor', 'labelSelected'); + + const listDiv = document.createElement("div"); + listDiv.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.container); + + if(description) { + const descriptionDiv = document.createElement("div"); + descriptionDiv.className = (SettingsBuilder.descriptionClassNames.description); + descriptionDiv.innerHTML = description; + descriptionDiv.style.marginBottom = "6px"; + listDiv.append(descriptionDiv); + } + + const table = document.createElement("table"); + table.style.width = "100%"; + listDiv.append(table); + + // top row with + button and labels + const topRow = document.createElement("tr"); + topRow.id = platform.platformId + "-list-row-top"; + + const addRowButton = document.createElement("button"); + addRowButton.innerText = "+"; + addRowButton.className = "bd-button"; + const addRowButtonColumn = document.createElement("th"); + addRowButtonColumn.append(addRowButton); + topRow.append(addRowButtonColumn); + + const inputColumnTitle = document.createElement("th"); + inputColumnTitle.innerText = header || "Value"; + inputColumnTitle.style.color = "var(--header-primary)"; + topRow.append(inputColumnTitle); + + table.append(topRow); + + // handle saving data to json + let saveTimeout; + const saveData = () => { + clearTimeout(saveTimeout); + + // delete all entries in old list + theList.length = 0; + + for(const row of table.children) { + if(row.id === platform.platformId + "-list-row-top") continue; + + const inputElement = row.children[1].children[0]; + const inputValue = inputElement.value; + + if(regex && !regex.test(inputValue)) { + inputElement.style.color = "red"; + continue; + } + inputElement.style.color = null; + + if(!inputValue) continue; + + theList.push(inputValue); + } + + saveTimeout = setTimeout(() => { + platform.saveData(); + }, 500); + } + + let id = 0; + + const addRow = (value, insertAtEnd=false) => { + const row = document.createElement("tr"); + row.style.width = "100%"; + row.id = platform.platformId + "-list-row-" + id.toString(); + + // X button + const removeButton = document.createElement("button"); + removeButton.className = "bd-button"; + removeButton.innerText = "X"; + removeButton.onclick = () => removeRow(row.id); + + const removeButtonColumn = document.createElement("th"); + removeButtonColumn.append(removeButton); + row.append(removeButtonColumn); + + // input + const platformInput = document.createElement("input"); + platformInput.className = SettingsBuilder.inputClassNames.input; + platformInput.style.width = "100%"; + platformInput.oninput = saveData; + if(value) platformInput.value = value; + + const platformInputColumn = document.createElement("th"); + platformInputColumn.style.width = "100%"; + platformInputColumn.append(platformInput); + row.append(platformInputColumn); + + if(insertAtEnd) table.append(row); + else table.insertBefore(row, table.children[1]); + + id++; + } + addRowButton.onclick = () => addRow(); + + const removeRow = (id) => { + table.removeChild(document.getElementById(id)); + if(table.children.length === 1) addRow(); + saveData(); + } + + if(theList.length <= 1) { + addRow(theList[0]); + } else { + for(const item of theList) { + addRow(item, true); + } + } + + // add divider + const divider = document.createElement("div"); + divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.divider); + divider.classList.add(ZeresPluginLibrary.DiscordClassModules.Dividers.dividerDefault); + listDiv.append(divider); + + return listDiv; + } + } + + /************* + ** STEAM ** + *************/ + + class Steam extends Platform { + + constructor() { + super("steam"); + + this.presenceCache = {}; + + this.loadData(); + if(this.enabled) { + this.start(); + } + } + + start() { + if(this.apiKey) { + this.updateCache(); + + // we are allowed 100000 requests/day = 64/minute + this.cacheUpdateinterval = setInterval(this.updateCache.bind(this), 10_000); + } + } + + serializeData() { + return { + enabled: this.enabled || false, + apiKey: this.apiKey || "", + usersMap: this.discordToSteamIDs || {}, + debug: this.debug || false + } + } + + deserializeData(data) { + this.enabled = data.enabled || false; + this.apiKey = data.apiKey || ""; + this.discordToSteamIDs = data.usersMap || {}; + this.debug = data.debug || false; + } + + async getPlayerSummaries(ids) { + const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${this.apiKey}&steamids=${ids.join(',')}`; + const req = await fetch(url); + + if(req.statusCode !== 200) { + console.error(req); + if(req.statusCode === 403) { + console.error(req); + if(this.apiKey) BdApi.alert("Your Steam API key is invalid! Steam has been disabled, reenable it in settings."); + else BdApi.alert("You haven't provided a Steam API key!"); + this.destroy(); + return; + } + console.error("HTTP error " + req.statusCode + " when fetching steam data"); + return; + } + + try { + const json_data = JSON.parse(req.body); + if (!json_data.response || !json_data.response.players) return; + this.log(json_data); + for (const playerSummary of json_data.response.players) { + this.processPlayerSummary(playerSummary); + } + } catch (e) { + console.error(e); + console.error(req); + err("Couldn't JSON Parse Steam response!"); + } + } + + processPlayerSummary(summary) { + // format: https://developer.valvesoftware.com/wiki/Steam_Web_API#GetPlayerSummaries_.28v0002.29 + if(summary.gameextrainfo) { + const statuses = ["Offline", "Playing", "Busy", "Away", "Snoozed", "Looking to trade", "Looking to play"]; + const previousPresence = this.presenceCache[summary.steamid]; + const playingSameGame = previousPresence && summary.gameextrainfo === previousPresence.name; + + const presence = { + application_id: customRpcAppId, + name: summary.gameextrainfo, + details: `${statuses[summary.personastate]} on Steam`, + type: 0, + timestamps: {start: playingSameGame ? previousPresence.timestamps.start : +new Date()}, + assets: { + large_image: "883490890377756682", + large_text: "Playing as " + summary.personaname + }, + username: summary.personaname, + priority: Priorities.NONPRIMARY_PLAYING + }; + + this.presenceCache[summary.steamid] = presence; + this.log(presence); + + if(!playingSameGame) this.updateUser(summary.steamid, this.discordToSteamIDs); + } else { + this.deletePresence(summary.steamid); + } + } + + deletePresence(id) { + if(this.presenceCache[id]) { + delete this.presenceCache[id]; + this.updateUser(id, this.discordToSteamIDs); + } + } + + updateCache() { + if(!this.enabled) return clearInterval(this.cacheUpdateinterval); + try { + const steam_ids = Object.values(this.discordToSteamIDs).flat(); + + // can only request 100 steam profiles at a time + for (let i = 0; i < steam_ids.length; i += 100) { + this.getPlayerSummaries(steam_ids.slice(i, i + 100)); + } + } catch (e) { + err(e); + } + } + + getPresence(discord_id) { + return super.getPresence(discord_id, this.discordToSteamIDs, this.presenceCache); + } + + getSettings(discordUserList, discordUsersDatalist) { + // enabled switch + const enabledSwitch = SettingsBuilder.enabledSwitch(this); + + // api key textbox + const textboxChange = (value) => { + this.apiKey = value; + if(this.enabled) { + SettingsBuilder.toggleEnabledSwitch(enabledSwitch); + } + } + const apiKeyTextbox = new ZeresPluginLibrary.Settings.Textbox("API Key", "Your Steam API key. Get one at https://steamcommunity.com/dev/apikey", this.apiKey, textboxChange); + setTimeout(() => { + apiKeyTextbox.getElement().children[0].children[2].innerHTML = `Your Steam API key. Get one at https://steamcommunity.com/dev/apikey` + }, 50); + + const userMapDiv = SettingsBuilder.userMapInterface(this, null, discordUsersDatalist, null, discordUserList, this.discordToSteamIDs, + "To get IDs, use a site such as Steam ID Finder and copy the SteamID64 (Dec).", "Steam ID", /^\d+$/); + + const debugSwitch = SettingsBuilder.debugSwitch(this); + + return SettingsBuilder.settingsPanel(this, enabledSwitch, apiKeyTextbox, userMapDiv, debugSwitch); + } + + destroy(pluginShutdown) { + this.enabled = false; + for(const id in this.presenceCache) this.deletePresence(id); + clearInterval(this.cacheUpdateinterval); + if(!pluginShutdown) this.saveData(); + } + } + + /***************** + ** MINECRAFT ** + *****************/ + + class Minecraft extends Platform { + constructor() { + super("minecraft"); + + this.mcUUIDToUsername = {}; + + this.loadData(); + this.UUIDs = [...new Set(Object.values(this.discordToMinecraftUUIDs).flat())] // remove duplicates + + this.log = this.log.bind(this); + this.updateUser = uuid => super.updateUser(uuid, this.discordToMinecraftUUIDs); + this.hypixel = new Hypixel(this.UUIDs, this.hypixelApiKey, this); + this.private = new MCPrivate(this.UUIDs, this.servers, this); + + if(this.enabled) { + this.start(); + } + } + + start() { + this.fetchUsernames().then(() => { + this.hypixel.start(); + }); + this.private.start(); + } + + loadData() { + const data = BdApi.loadData(pluginName, "minecraft"); + if(data) super.loadData(); + else { + // fetch data from old version of CrossPlatformPlaying + const data = BdApi.loadData(pluginName, "hypixel"); + if(data) { + this.enabled = data.enabled || false; + this.hypixelApiKey = data.apiKey || ""; + this.discordToMinecraftUUIDs = data.usersMap || {}; + this.debug = data.debug || false; + } + this.saveData(); + this.loadData(); + } + } + + serializeData() { + return { + enabled: this.enabled || false, + hypixelApiKey: this.hypixelApiKey || "", + usersMap: this.removeDashesFromUUIDs(this.discordToMinecraftUUIDs || {}), + servers: this.servers || [], + debug: this.debug || false + } + } + + deserializeData(data) { + this.enabled = data.enabled || false; + this.hypixelApiKey = data.hypixelApiKey || ""; + this.discordToMinecraftUUIDs = data.usersMap || {}; + this.servers = data.servers || []; + this.debug = data.debug || false; + } + + removeDashesFromUUIDs(discordToMinecraftUUIDs) { + for(const UUIDs of Object.values(discordToMinecraftUUIDs)) { + for(const [index, UUID] of Object.entries(UUIDs)) { + UUIDs[index] = UUID.replaceAll(/-/g, ""); + } + } + return discordToMinecraftUUIDs; + } + + getPresence(discord_id) { + const uuids = this.discordToMinecraftUUIDs[discord_id]; + if (!uuids) return; + + const presences = []; + + for (const uuid of uuids) { + const hypixelPresence = this.hypixel.getPresence(uuid); + if(hypixelPresence) presences.push(hypixelPresence); + + const privatePresences = this.private.getPresence(uuid); + if(privatePresences) presences.push(...privatePresences); + } + + if(presences.length) return presences; + } + + destroy(pluginShutdown) { + this.enabled = false; + this.hypixel.destroy(); + this.private.destroy(); + if(!pluginShutdown) this.saveData(); + } + + async fetchUsernames() { + await Promise.all(this.UUIDs.map(this.fetchUsername.bind(this))); + } + + async fetchUsername(UUID) { + // https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape + const req = await fetch(`https://sessionserver.mojang.com/session/minecraft/profile/${UUID}`); + const json = JSON.parse(req.body); + this.mcUUIDToUsername[UUID] = json.name; + } + + getSettings(discordUserList, discordUsersDatalist) { + // enabled switch + const enabledSwitch = SettingsBuilder.enabledSwitch(this); + + // api key textbox + const textboxChange = (value) => { + this.apiKey = value; + if(this.enabled) { + SettingsBuilder.toggleEnabledSwitch(enabledSwitch); + } + } + const apiKeyTextbox = new ZeresPluginLibrary.Settings.Textbox("Hypixel API Key", "Your Hypixel API key. Use the /api command in-game to get it.", this.hypixelApiKey, textboxChange); + + // uuid regex from https://github.com/bribes/Minecraft-UUID-RegEx + const userMapDiv = SettingsBuilder.userMapInterface(this, null, discordUsersDatalist, null, discordUserList, this.discordToMinecraftUUIDs, + "To get UUIDs, use NameMC.", "Minecraft UUID", /^([0-9a-f]{8})(?:-|)([0-9a-f]{4})(?:-|)(4[0-9a-f]{3})(?:-|)([89ab][0-9a-f]{3})(?:-|)([0-9a-f]{12})$/); + // todo make them username instead of UUID + // todo make it work when adding/removing users (changing hypixel timeout, etc.) + + const serverListDiv = SettingsBuilder.list(this, this.servers, + "A list of Minecraft servers to regularly query. Only works for small private servers, and only when there are less than ~12 players online.", "Server URL"); + + const debugSwitch = SettingsBuilder.debugSwitch(this); + + return SettingsBuilder.settingsPanel(this, enabledSwitch, apiKeyTextbox, userMapDiv, serverListDiv, debugSwitch); + } + } + + + /*************** + ** HYPIXEL ** + ***************/ + + class Hypixel { + + constructor(UUIDs, apiKey, mc) { + this.UUIDs = UUIDs; + this.apiKey = apiKey; + this.log = mc.log; + this.updateUser = mc.updateUser; + + this.timeouts = []; + this.presenceCache = {}; + this.games = { + MAIN: {name: "Main Lobby", modes: []} + }; + } + + start() { + if(this.apiKey) { + this.fetchGames().then(() => { + this.updateCache(); + this.cacheInterval = setInterval(this.updateCache.bind(this), this.calculateRefreshInterval()); + }); + } + } + + getPresence(uuid) { + return this.presenceCache[uuid]; + } + + destroy() { + for(const uuid in this.presenceCache) this.deletePresence(uuid); + clearInterval(this.cacheInterval); + for(const timeout of this.timeouts) + clearTimeout(timeout); + } + + async fetchGames() { + const modes_req = await fetch("https://raw.githubusercontent.com/slothpixel/hypixelconstants/master/build/modes.json"); + const modes = JSON.parse(modes_req.body); + + const extract_modes = game => { + if(!game.modes) return []; + let modes = {}; + for(const mode of game.modes) { + if(mode.modes) modes = {...modes, ...extract_modes(mode)} + else modes[mode.key] = mode.name; + } + return modes; + } + + for(const game of modes) { + this.games[game.key] = { + name: game.check || game.name, + modes: extract_modes(game) + } + } + + // slothpixel is slow to update when new games come out (e.g. seasonal minigames) + const hypixel_req = await fetch("https://api.hypixel.net/resources/games"); + const hypixel_modes = JSON.parse(hypixel_req.body); + if(!hypixel_modes.success) return; + + for(const game_id of Object.keys(hypixel_modes.games)) { + const game = hypixel_modes.games[game_id]; + const game_data = this.games[game_id] || {name: game.name, modes: {}}; + + game_data.modes = { + ...game.modeNames, + ...game_data.modes + } + + this.games[game_id] = game_data; + } + } + + calculateRefreshInterval() { + // hypixel allows 2 requests per second + // each player takes 1 request if offline, 2 if online + // to be safe, I do max 1 request / 2 sec -> 1 player / 4 sec + // aka if there are 3 players, we request all players every 12 seconds + return this.UUIDs.length * 4000; + + } + + async getPlayerStatus(uuid) { + const url = `https://api.hypixel.net/status?key=${this.apiKey}&uuid=${uuid}`; + const req = await fetch(url); + + if(req.statusCode !== 200) { + console.error(req); + if(req.statusCode === 403) { + if(this.apiKey) BdApi.alert("Your Hypixel API key is invalid! The Hypixel plugin has been disabled, reenable it in settings."); + else BdApi.alert("You haven't provided a Hypixel API key!"); + this.destroy(); + } + else console.error("HTTP error " + req.statusCode + " when fetching hypixel player status data"); + return; + } + + try { + const json_data = JSON.parse(req.body); + if(json_data.success && json_data.session.online) { + this.log(json_data); + this.getPlayerInfo(uuid, json_data.session); + } else { + this.deletePresence(uuid); + if(!json_data.success) { + console.error(json_data); + err("Could not fetch player status for player with UUID " + uuid + "!"); + } + } + } catch (e) { + console.error(req); + console.error(e); + err("Couldn't JSON Parse Hypixel status response!"); + } + } + + async getPlayerInfo(uuid, session) { + const url = `https://api.hypixel.net/player?key=${this.apiKey}&uuid=${uuid}`; + const req = await fetch(url); + + if(req.statusCode !== 200) { + console.error("HTTP error " + req.statusCode + " when fetching hypixel player info", req); + return; + } + + try { + const json_data = JSON.parse(req.body); + if (json_data.success) { + this.log(json_data); + this.processPlayerData(uuid, json_data.player, session); + } else { + console.error(json_data); + err("Could not fetch player info for player with UUID " + uuid + "!"); + } + } catch (e) { + console.error(e); + console.error(req); + err("Couldn't JSON Parse Hypixel player response!"); + } + } + + processPlayerData(uuid, player, session) { + try { + const format = s => { + const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1); + return "(?) " + s.split(/[ _]/).map(capitalize).join(' '); + } + + const game = this.games[session.gameType] || {}; + const mode = session.mode === "LOBBY" ? "In the Lobby" : + game.modes ? game.modes[session.mode] : + format(session.mode); + + const presence = { + application_id: customRpcAppId, + name: "Hypixel", + details: "Playing " + game.name || format(session.gameType), + state: mode, + type: 0, + timestamps: {start: player.lastLogin}, + assets: { + large_image: "883490391964385352", + large_text: "Playing as " + player.displayname, + }, + username: player.displayname + }; + + if(session.map) { + presence.assets.small_image = "883498326580920403"; + presence.assets.small_text = session.map; + } + + const previousPresence = this.presenceCache[uuid]; + if(!previousPresence) this.updateUser(uuid); + + this.presenceCache[uuid] = presence; + this.log(presence); + } catch(e) { + console.error(e); + console.error(player, session); + err("Error while processing Hypixel data!"); + } + } + + deletePresence(uuid) { + if(this.presenceCache[uuid]) { + delete this.presenceCache[uuid]; + this.updateUser(uuid); + } + } + + updateCache() { + try { + for (let i = 0; i < this.UUIDs.length; i++) { + this.timeouts.push(setTimeout(() => { + this.getPlayerStatus(this.UUIDs[i]); + this.timeouts.shift(); + }, 4000 * i)); + } + } catch (e) { + err(e); + } + } + } + + /****************** + ** MC PRIVATE ** + ******************/ + + class MCPrivate { + + constructor(UUIDs, servers, mc) { + this.UUIDs = UUIDs; + this.servers = servers; + this.log = mc.log; + this.updateUser = mc.updateUser; + + this.presenceCache = {}; + this.pingTimeouts = []; + this.bufferedMessages = {}; + } + + start() { + this.pingServers(); + this.interval = setInterval(this.pingServers.bind(this), 30_000); + } + + pingServers() { + for(const url of this.servers) { + this.pingServer(url); + } + } + + parseServerAddress(url) { + const colonIndex = url.indexOf(':'); + if(colonIndex === -1) return [url, 25565]; + return[url.substring(0, colonIndex), parseInt(url.substring(colonIndex + 1))]; + } + + pingServer(url) { + const [address, port] = this.parseServerAddress(url); + + const ondata = (data) => { + clearTimeout(timeout); + removeFromList(this.pingTimeouts, timeout); + + const roundTrip = Date.now() - sentAt; + this.log(`Ping to ${url} took ${roundTrip}ms`); + + this.parseData(data, url); + preload.net.closeSocket(socketId); + } + const onerror = (e) => { + if(e.message.includes("ETIMEDOUT")) return; + console.error("Error connecting to Minecraft server", e); + } + const socketId = preload.net.createSocket(address, port, null, ondata, onerror); + + // handshake + const handshake = []; + handshake.push(this.varInt(47)) // protocol version (1.8) + handshake.push(this.utfString(address)); + handshake.push(this.shortInt(port)); + handshake.push(this.varInt(1)); // status query (as opposed to login) + this.sendPayload(socketId, 0, Buffer.concat(handshake)); + + // request + this.sendPayload(socketId, 0, Buffer.from([])); + + // in case of no response + const sentAt = Date.now(); + let timeout = setTimeout(() => { + removeFromList(this.pingTimeouts, timeout); + this.log(`Server ${url} didn't respond within 5 seconds! Assuming the server is down`); + this.clearServerPresences(url); + }, 5000); + this.pingTimeouts.push(timeout); + } + + varInt(int) { + // https://wiki.vg/Protocol#VarInt_and_VarLong + const bytes = []; + while (true) { + if ((int & ~0x7F) === 0) { + bytes.push(int); + return Buffer.from(bytes); + } + bytes.push((int & 0x7F) | 0x80); + int >>>= 7; + } + } + + utfString(str) { + return Buffer.concat([this.varInt(str.length), Buffer.from(str, 'utf-8')]); + } + + shortInt(int) { + const buf = Buffer.alloc(2); + buf.writeUInt16BE(int); + return buf; + } + + sendPayload(socketId, packetId, payload) { + packetId = this.varInt(packetId); + const packetLength = this.varInt(packetId.length + payload.length); + const packet = Buffer.concat([packetLength, packetId, payload]); + + preload.net.sendToSocket(socketId, packet); + } + + readVarInt (buf, start=0) { + // https://github.com/PassTheMayo/minecraft-server-util/blob/master/src/util/varint.ts#L1 + let value = 0; + let i = start; + let currentByte; + + do { + currentByte = buf[i]; + value |= ((currentByte & 0x7F) << (7 * (i - start))); + i++; + } while ((currentByte & 0x80) !== 0); + + return [value, i]; + } + + parseData(buf, url) { + try { + if(this.bufferedMessages[url]) { + buf = Buffer.concat([this.bufferedMessages[url], buf]); + delete this.bufferedMessages[url]; + } + + const [packetLength, i] = this.readVarInt(buf); + if(buf.length < packetLength) { + this.log(`Received chunk of data from ${url}, waiting for the rest...`); + this.bufferedMessages[url] = buf; + return; + } + + const [packetID, j] = this.readVarInt(buf, i); + const [JSONLength, k] = this.readVarInt(buf, j); + const unparsed = buf.slice(k, k + JSONLength).toString(); + + const data = JSON.parse(unparsed); + this.log(data); + + this.processData(data, url); + } catch(e) { + console.error(buf); + console.error(e); + err("Failed to parse server data for " + url); + } + } + + processData(data, url) { + const players = data.players.sample || []; + for(const player of players) { + const uuid = player.id.replaceAll('-', ""); + let previousPresence; + if(this.presenceCache[uuid] === undefined) this.presenceCache[uuid] = {}; + else previousPresence = this.presenceCache[uuid][url]; + + const presence = { + application_id: customRpcAppId, + name: "Minecraft", + details: "Playing on " + url, + state: "In Game", + party: { + id: url, + size: [data.players.online, data.players.max] + }, + type: 0, + timestamps: { + start: previousPresence ? previousPresence.timestamps.start : Date.now() + }, + assets: { + large_image: formatAsset(`https://crafatar.com/renders/head/${uuid}?overlay=true`), + large_text: data.version.name, + small_image: formatAsset(data.favicon), + small_text: (data.description.text || data.description.toString() || "").replaceAll(/( {2})|\n/g, ' ').replaceAll(/ยง./g, '') + }, + username: player.name + }; + + if(previousPresence) presence.timestamps.start = previousPresence.timestamps.start; + else this.updateUser(uuid); + + this.presenceCache[uuid][url] = presence; + this.log(presence); + } + + // clear players not on server anymore + const UUIDs = players.map(player => player.id.replaceAll('-', "")); + for(const uuid in this.presenceCache) { + if(!UUIDs.includes(uuid)) { + this.deletePresence(uuid); + } + } + } + + deletePresence(uuid) { + if(this.presenceCache[uuid]) { + delete this.presenceCache[uuid]; + this.updateUser(uuid); + } + } + + clearServerPresences(url) { + for(const presences of Object.values(this.presenceCache)) + delete presences[url]; + } + + getPresence(uuid) { + if(this.presenceCache[uuid]) return Object.values(this.presenceCache[uuid]); + } + + destroy() { + clearInterval(this.interval); + for(const uuid in this.presenceCache) this.deletePresence(uuid); + } + } + + /************** + ** TWITCH ** + **************/ + + class Twitch extends Platform { + + constructor() { + super("twitch"); + + this.presenceCache = {}; + this.usersList = { + idToName: {}, + nameToId: {} + } + + this.loadData(); + if(this.enabled) { + this.start(); + } + } + + start() { + if(this.oauthKey) { + this.getOnlineFriends(); + this.interval = setInterval(this.getOnlineFriends.bind(this), 60_000); + } + } + + serializeData() { + return { + enabled: this.enabled || false, + oauthKey: this.oauthKey || "", + usersMap: this.discordToTwitchID || {}, + debug: this.debug || false + } + } + + deserializeData(data) { + this.enabled = data.enabled || false; + this.oauthKey = data.oauthKey || ""; + this.discordToTwitchID = data.usersMap || {}; + this.debug = data.debug || false; + } + + destroy(pluginShutdown) { + this.enabled = false; + for(const id in this.presenceCache) this.deletePresence(id); + clearInterval(this.interval); + if(!pluginShutdown) this.saveData(); + } + + async getOnlineFriends() { + if(!this.enabled || !this.oauthKey) return this.destroy(); + + const data = await fetch("https://gql.twitch.tv/gql", { + method: "POST", + headers: { + "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko", + "Authorization": `OAuth ${this.oauthKey}` + }, + body: JSON.stringify([{operationName: "OnlineFriends", variables: {}, extensions: {persistedQuery: {version: 1, sha256Hash: "4fecfced6ce413ffa2eee3c6ce09cddd8fb251763c594c26fba5108cf2b92e69"}}}]) + }); + + try { + if(data.body.length <= 2) return; + const json_data = JSON.parse(data.body); + if (json_data.status === 401) { + this.destroy(); + console.error(json_data); + return err("Error 401 when fetching twitch friends!"); + } + + if(json_data.status === 502) { + console.error(json_data); + return console.error("Error 502 when fetching twitch friends!"); + } + + if(json_data[0].errors) { + console.error(json_data); + if(["service timeout", "service error", "service unavailable"].includes(json_data[0].errors[0].message)) return; + console.error(data); + return err("Twitch friends request returned error!"); + + } + + if(!json_data[0].data.currentUser) { + this.destroy(); + console.error(json_data); + return err("Twitch friends request returned no currentUser! Is your account active?"); + } + + this.log(json_data); + + const streamerList = this.extractStreamerList(json_data[0].data.currentUser.friends.edges); + const streamsMetadata = await this.fetchStreamsMetadata(streamerList); + if(!streamerList || !streamsMetadata) return; + + this.usersList = { + idToName: {}, + nameToId: {} + } + for (const friend of json_data[0].data.currentUser.friends.edges) { + this.processFriend(friend.node, streamsMetadata); + } + } catch (e) { + console.error(e); + console.error(data); + err("Couldn't JSON Parse Twitch response!", data); + } + } + + extractStreamerList(friend_edges) { + const streamerLogins = []; + for(const edge of friend_edges) { + if(edge.node.activity && + edge.node.activity.type === "WATCHING" && + edge.node.activity.user.stream && + edge.node.activity.user.login && + !streamerLogins.includes(edge.node.activity.user.login)) + streamerLogins.push(edge.node.activity.user.login) + } + return streamerLogins; + } + + async fetchStreamsMetadata(streamerLogins) { + const requestBody = []; + for(const streamerLogin of streamerLogins) { + requestBody.push({ + "operationName": "StreamMetadata", + "variables": {"channelLogin": streamerLogin}, + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "059c4653b788f5bdb2f5a2d2a24b0ddc3831a15079001a3d927556a96fb0517f" + } + } + }); + } + + const data = await fetch("https://gql.twitch.tv/gql", { + method: "POST", + headers: { + "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko", + "Authorization": "OAuth " + this.oauthKey + }, + body: JSON.stringify(requestBody) + }); + const body = JSON.parse(data.body); + + try { + const streamsMetadata = {}; + for(let i = 0; i < body.length; i++) { + const stream = body[i].data.user; + streamsMetadata[streamerLogins[i]] = { + title: stream.lastBroadcast.title, + viewers: stream.stream.viewersCount, + start: +new Date(stream.stream.createdAt), + profilePicture: stream.profileImageURL//.replace("70x70", "300x300") // 600x600 also works + } + } + + return streamsMetadata; + } catch(e) { + console.error(e); + console.error(data); + console.error(body); + err("Error while trying to fetch twitch metadata!"); + } + } + + processFriend(friend, streamsMetadata) { + try { // add friend to usersList + this.usersList.idToName[friend.id] = friend.login; + this.usersList.nameToId[friend.login] = friend.id; + + if(friend.activity) { + let previousPresence = this.presenceCache[friend.id]; + if(previousPresence) previousPresence = previousPresence(); + if(friend.activity.type === "WATCHING") { + if(!friend.activity.user.stream || !friend.activity.user.stream.game) // they are watching someone that is no longer streaming (their presence hasn't updated yet) + return delete this.presenceCache[friend.id]; + + const isWatchingSamePerson = previousPresence && previousPresence.details.substr(9) === friend.activity.user.displayName; + const away = friend.availability === "AWAY"; + const metadata = streamsMetadata[friend.activity.user.login]; + + // the way type 3 (watching) presences are rendered is weird + // the large_text is shown both when hovering the image but also underneath the details (this is because that's how spotify Listening activities are rendered) + // in the "Active Now" tab in the friends list it only says "watching a stream" instead of rendering the whole activity like type 0 (playing) does + // the only reason type 3 exists in the first place is for YouTube Together as far as I can tell, so not much thought has been put into it + + // also timestamps (01:23:45 elapsed) aren't rendered, so the presences are stored as functions + // when the presence is requested, the function is called and parses the current time into the large_text + + this.presenceCache[friend.id] = () => { + return { + application_id: away ? customRpcAppId : null, + name: "Twitch", + details: `Watching ${friend.activity.user.displayName}`, + state: metadata.title, + type: 3, + party: {id: friend.activity.user.login}, + timestamps: {start: isWatchingSamePerson ? previousPresence.timestamps.start : +new Date()}, + assets: { + large_image: away ? "899072297216913449" : "twitch:" + friend.activity.user.login, + large_text: `${friend.activity.user.stream.game.displayName} | ๐Ÿ‘ค ${metadata.viewers} | ๐Ÿ• ${this.parseStartTime(metadata.start)}`, + small_image: formatAsset(metadata.profilePicture), + small_text: friend.activity.user.displayName + }, + username: friend.displayName, + priority: Priorities.SECONDARY + } + } + } else if(friend.activity.type === "STREAMING") this.presenceCache[friend.id] = () => { + return { + name: "Twitch", + state: friend.activity.stream.game.displayName, + type: 1, + assets: { + large_image: "twitch:" + friend.login, + large_text: "As " + friend.displayName + }, + url: "https://twitch.tv/" + friend.login, + id: "", + username: friend.displayName, + priority: Priorities.NONPRIMARY_PLAYING // should this be above the actual game presence? + } + } + + this.log(this.presenceCache[friend.id]()); + + if(!previousPresence) this.updateUser(friend.id, this.discordToTwitchID); + } else { + this.deletePresence(friend.id); + } + } catch(e) { + console.error(friend, streamsMetadata); + console.error(e); + err("Error while processing Twitch friend data! " + e); + } + } + + deletePresence(id) { + if(this.presenceCache[id]) { + delete this.presenceCache[id]; + this.updateUser(id, this.discordToTwitchID); + } + } + + parseStartTime(start) { + let streamingFor = Date.now() - start; + streamingFor -= streamingFor % 5000; // to mitigate discord's weird polling/rendering frequency + const sec = Math.floor(streamingFor / 1000 % 60), + min = Math.floor(streamingFor / 1000 / 60 % 60), + hour = Math.floor(streamingFor / 1000 / 60 / 60); + + const p = n => n.toString().padStart(2, '0'); // 5:3 -> 05:03 + + if(hour) return `${hour}:${p(min)}:${p(sec)}`; + return `${p(min)}:${p(sec)}`; + } + + getPresence(discord_id) { + const presenceFunctions = super.getPresence(discord_id, this.discordToTwitchID, this.presenceCache); + if(presenceFunctions && presenceFunctions.length) return presenceFunctions.map(presenceFunction => presenceFunction()); + } + + getSettings(discordUserList, discordUsersDatalist) { + // enabled switch + const enabledSwitch = SettingsBuilder.enabledSwitch(this); + + // api key textbox + const textboxChange = (value) => { + this.oauthKey = value; + if(this.enabled) { + SettingsBuilder.toggleEnabledSwitch(enabledSwitch); + } + } + const apiKeyTextbox = new ZeresPluginLibrary.Settings.Textbox("OAuth Key", "How to get your Twitch OAuth key", this.oauthKey, textboxChange); + setTimeout(() => { + apiKeyTextbox.getElement().children[0].children[2].innerHTML = `How to get your Twitch OAuth key` + }, 50); + + const datalist = SettingsBuilder.createDatalist("twitch", Object.keys(this.usersList.nameToId)); + const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, this.usersList, discordUserList, this.discordToTwitchID, null, "Twitch username", /^\d+$/); + + const debugSwitch = SettingsBuilder.debugSwitch(this); + + return SettingsBuilder.settingsPanel(this, enabledSwitch, apiKeyTextbox, userMapDiv, debugSwitch); + } + } + + /************ + ** RIOT ** + ************/ + + class Riot extends Platform { + + constructor() { + super("riot"); + + this.riotPUUIDToUsername = {}; + this.riotPUUIDToSummonerName = {}; + + const log = this.log.bind(this); + const updateUser = puuid => super.updateUser(puuid, this.discordToRiotPUUIDs); + + this.valorant = new Valorant(this.riotPUUIDToUsername, log, updateUser); + this.lol = new Lol(this.riotPUUIDToUsername, this.riotPUUIDToSummonerName, log, updateUser); + this.wildRift = new WildRift(this.riotPUUIDToUsername, this.riotPUUIDToSummonerName, log, updateUser); + + this.loadData(); + if (this.enabled) { + this.start(); + } + } + + start() { + if(this.cookies) { + this.valorant.fetchRpcAssets() + this.lol.fetchData(); + this.startXMPPConnection(); + } + } + + serializeData() { + return { + enabled: this.enabled || false, + cookies: this.cookies || "", + usersMap: this.discordToRiotPUUIDs || {}, + debug: this.debug || false + } + } + + deserializeData(data) { + this.enabled = data.enabled || false; + this.cookies = data.cookies || ""; + this.discordToRiotPUUIDs = data.usersMap || {}; + this.debug = data.debug || false; + } + + + getPresence(discord_id) { + const puuids = this.discordToRiotPUUIDs[discord_id]; + if (!puuids) return; + + const presences = []; + + for (const puuid of puuids) { + const valPresence = this.valorant.getPresence(puuid); + if(valPresence) presences.push(valPresence); + + const lolPresence = this.lol.getPresence(puuid); + if(lolPresence) presences.push(lolPresence); + + const wrPresence = this.wildRift.getPresence(puuid); + if(wrPresence) presences.push(wrPresence); + } + + if(presences.length) return presences; + } + + destroy(pluginShutdown) { + this.enabled = false; + clearInterval(this.reconnectInterval); + clearTimeout(this.heartbeat); + if(this.socket) { + preload.tls.sendToSocket(this.sock_id, ""); + preload.tls.closeSocket(this.sock_id, true); + } + + this.valorant.deleteAllPresences(); + this.lol.deleteAllPresences(); + this.wildRift.deleteAllPresences(); + + if(!pluginShutdown) this.saveData(); + } + + async refreshToken(cookies) { + const res = await fetch("https://auth.riotgames.com/authorize?" + + "redirect_uri=https%3A%2F%2Fplayvalorant.com%2Fopt_in&" + + "client_id=play-valorant-web-prod&" + + "response_type=token%20id_token&" + + "scope=account%20ban%20link%20lol%20offline_access%20openid&" + + "nonce=123", { + method: "GET", + headers: { + // "cloudflare bitches at us without a user-agent" - molenzwiebel + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36", + "cookie": cookies + }, + }); + this.updateCookies(res.headers['set-cookie']); + const uri = res.headers.location; + return uri.split(/[=&]/, 2)[1]; + } + + updateCookies(newCookies) { + const cookies = {}; + + // parse old cookies + for(const cookie of this.cookies.split("; ")) { + const split = cookie.split('='); + cookies[split.shift()] = split.join("="); + } + + // replace with new cookies + for(const cookie of newCookies) { + const sep = cookie.indexOf("="); + cookies[cookie.slice(0, sep)] = cookie.slice(sep + 1, cookie.indexOf(';')); + } + + const cookieList = []; + for (let [key, value] of Object.entries(cookies)) { + cookieList.push(key + "=" + value); + } + + this.cookies = cookieList.join("; "); + this.saveData(); + } + + getCookiesFromLauncher() { + // I doubt this works on Mac + const filepath = process.env.LOCALAPPDATA + "/Riot Games/Riot Client/Data/RiotGamesPrivateSettings.yaml"; + + let fileContents; + try { + fileContents = fs.readFileSync(filepath).toString(); + } catch(e) { + return [false, e]; + } + + const parsedContents = this.parseYaml(fileContents); + if(parsedContents) { + const cookies = []; + for(const cookie of parsedContents["riot-login"].persist.session.cookies) { + cookies.push(cookie.name + '=' + cookie.value); + } + return [true, cookies.join("; ")]; + } + + return [false, "Could not parse file! Is it corrupt?"]; + } + + parseYaml(yaml) { + const result = {}; + const path = []; + let depth = 0; + + let currentObject = result; + let currentObjectName = ""; + + for(const line of yaml.split('\n')) { + if(!line) continue; + + const array_matches = line.match(/^( {4})*(- {3})(.+):( (.+))?/); + if(array_matches) { + let array = path[path.length - 1]; + currentObject = {}; + if(Array.isArray(array)) { + array.push(currentObject); + } else { + array = [currentObject]; + path[path.length - 1][currentObjectName] = array; + path.push(array); + } + + currentObject[array_matches[3]] = JSON.parse(array_matches[5]); + continue; + } + + const matches = line.match(/^(( {4})*)(.+):( (.+))?/); + if(matches) { + while((matches[1] && matches[1].length || 0) / 4 < depth) { + currentObject = path.pop(); + depth--; + } + + if(matches[4]) { + currentObject[matches[3]] = JSON.parse(matches[5]); + } else { + let newObject = {}; + currentObjectName = matches[3]; + currentObject[currentObjectName] = newObject; + path.push(currentObject); + currentObject = newObject; + depth++; + } + continue; + } + + console.error("no matches for line: " + line); + return false; + } + + return result; + } + + async getPAS(token) { + const res3 = await fetch("https://riot-geo.pas.si.riotgames.com/pas/v1/service/chat", { + method: "GET", + headers: { + "Authorization": "Bearer " + token, + }, + }); + return res3.body + } + + decodeToken(token) { + return JSON.parse(atob(token.split('.')[1])); + } + + // xmpp stuff + establishXMPPConnection(RSO, PAS) { + try { + this.presenceCache = {}; + + const region = this.decodeToken(PAS).affinity; + const address = this.XMPPRegionURLs[region]; + const port = 5223; + const XMPPRegion = this.XMPPRegions[region]; + + const messages = [ + ``, "", + `${RSO}${PAS}`, + ``, "", + "", + "", + "", // get friends list + "" + ] + + const onconnect = () => { + try { + this.socketReady = true; + + this.log("Connected!"); + + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + + sendNext(); + } catch (e) { + err(e); + } + } + + let bufferedMessage = ""; + const ondata = (data) => { + try { + data = bufToString(data); + this.log("<- " + data); + if(messages.length > 0) sendNext(); + + // handle riot splitting messages into multiple parts + if(data.startsWith("')).split(' ', 1)[0]; + + // check for self closing tag eg + if(data.search(/<[^<>]+\/>/) === 0) data = data.replace("/>", `>`); + + let closingTagIndex = data.indexOf(``); + if(closingTagIndex === -1) { + // message is split, we need to wait for the end + bufferedMessage = data; + break; + } + + // check for tag inside itself eg + // this happens when you send a message to someone + let containedTags = 0; + let nextTagIndex = data.indexOf(`<${firstTagName}`, 1); + while(nextTagIndex !== -1 && nextTagIndex < closingTagIndex) { + containedTags++; + nextTagIndex = data.indexOf(`<${firstTagName}`, nextTagIndex + 1); + } + + while(containedTags > 0) { + closingTagIndex = data.indexOf(``, closingTagIndex + 1); + containedTags--; + } + + const firstTagEnd = closingTagIndex + ``.length; + bufferedMessage = data.substr(firstTagEnd); // will be empty string if only one tag + data = data.substr(0, firstTagEnd); + + if(firstTagName === "presence") { + this.valorant.processXMLData(data); + this.lol.processXMLData(data); + this.wildRift.processXMLData(data); + } else if(firstTagName === "iq") { + if(data.includes("jabber:iq:riotgames:roster")) { + this.processFriendsList(data); + } else if(data.includes("_xmpp_session") && data.includes("urn:ietf:params:xml:ns:xmpp-session")) { + this.processOwnUsername(data, this.decodeToken(PAS).sub); + } + } else if(firstTagName === "failure") { + const errorStartIndex = data.indexOf('>') + 1; + const error = data.substring(errorStartIndex, closingTagIndex); + if(data.includes("token-expired")) { + // BdApi.alert("token expired!") + this.log(error); + this.restart(); + } else { + console.error(data); + err("Riot XMPP Connection failed! " + data); + this.destroy(); + } + } + + data = ""; + } + } catch (e) { + err(e); + } + } + + const onerror = console.error; + const onclose = () => { + if(!this.enabled) return this.log("Socket disconnected!"); + + console.error("Riot Connection Closed! Retrying in 5 seconds..."); + + if(this.reconnectInterval) return; + + this.reconnectInterval = setInterval(() => { + this.log("Reconnecting..."); + this.establishXMPPConnection(RSO, PAS); + }, 5000); + + clearTimeout(this.heartbeat); + } + + this.socketReady = false; + this.sock_id = preload.tls.createSocket(address, port, {}, onconnect, ondata, onerror, onclose); + + const send = data => { + try { + if(this.socketReady) preload.tls.sendToSocket(this.sock_id, data, "utf8", () => { + if(data !== " ") this.log("-> " + data) + }); + + clearTimeout(this.heartbeat); + this.heartbeat = setTimeout(() => send(" "), 150_000); + } catch (e) { + err(e); + } + } + + const sendNext = () => send(messages.shift()); + + } catch (e) { + err(e); + } + } + + async startXMPPConnection() { + // todo do not hardcode these, get these from clientconfig endpoint + this.XMPPRegions = {"as2":"as2","asia":"jp1","br1":"br1","eu":"ru1","eu3":"eu3","eun1":"eu2","euw1":"eu1","jp1":"jp1","kr1":"kr1","la1":"la1","la2":"la2","na1":"na1","oc1":"oc1","pbe1":"pb1","ru1":"ru1","sea1":"sa1","sea2":"sa2","sea3":"sa3","sea4":"sa4","tr1":"tr1","us":"la1","us-br1":"br1","us-la2":"la2","us2":"us2"}; + this.XMPPRegionURLs = {"as2":"as2.chat.si.riotgames.com","asia":"jp1.chat.si.riotgames.com","br1":"br.chat.si.riotgames.com","eu":"ru1.chat.si.riotgames.com","eu3":"eu3.chat.si.riotgames.com","eun1":"eun1.chat.si.riotgames.com","euw1":"euw1.chat.si.riotgames.com","jp1":"jp1.chat.si.riotgames.com","kr1":"kr1.chat.si.riotgames.com","la1":"la1.chat.si.riotgames.com","la2":"la2.chat.si.riotgames.com","na1":"na2.chat.si.riotgames.com","oc1":"oc1.chat.si.riotgames.com","pbe1":"pbe1.chat.si.riotgames.com","ru1":"ru1.chat.si.riotgames.com","sea1":"sa1.chat.si.riotgames.com","sea2":"sa2.chat.si.riotgames.com","sea3":"sa3.chat.si.riotgames.com","sea4":"sa4.chat.si.riotgames.com","tr1":"tr1.chat.si.riotgames.com","us":"la1.chat.si.riotgames.com","us-br1":"br.chat.si.riotgames.com","us-la2":"la2.chat.si.riotgames.com","us2":"us2.chat.si.riotgames.com"}; + + const access_token = await this.refreshToken(this.cookies); + if(!access_token.startsWith('e')) { + this.log("Riot Access Token: " + access_token); + err("Invalid Riot access token! Most likely your cookies are either invalid or expired."); + return this.destroy(); + } + + const pas_token = await this.getPAS(access_token); + if(!access_token.startsWith('e')) { + this.log("Invalid Riot PAS: " + pas_token); + // riot sometimes returns error 520 when fetching PAS + // note: this is a very dangerous workaround + return setTimeout(this.startXMPPConnection.bind(this), 1000); + } + + if(!this.discordToRiotPUUIDs[discord_id]) + this.discordToRiotPUUIDs[discord_id] = [this.decodeToken(pas_token).sub]; + + this.establishXMPPConnection(access_token, pas_token); + } + + static extractDataFromXML(xml, tagName, startIndex, endIndex) { + const dataStartIndex = xml.indexOf(`<${tagName}>`, startIndex, endIndex); + const dataEndIndex = xml.indexOf(``, dataStartIndex, endIndex); + if(dataStartIndex >= 0 && dataEndIndex > dataStartIndex) { + const data = xml.substring(dataStartIndex + tagName.length + 2, dataEndIndex) + if(data) return data; + } + } + + processOwnUsername(data, puuid) { + const usernameIndex = data.indexOf("name=") + 6; + const username = data.substring(usernameIndex, data.indexOf("' ", usernameIndex)); + + const taglineIndex = data.indexOf("tagline=") + 9; + const tagline = data.substring(taglineIndex, data.indexOf("'/>", taglineIndex)); + + this.riotPUUIDToUsername[puuid] = `${username}#${tagline}`; + this.log("My username is " + this.riotPUUIDToUsername[puuid]); + + const lolTagIndex = data.indexOf(" -1) { + const lolNameIndex = data.indexOf("name=", lolTagIndex) + 6; + const lolName = data.substring(lolNameIndex, data.indexOf("'", lolNameIndex)); + + this.riotPUUIDToSummonerName[puuid] = lolName + this.log("My summoner name is " + lolName); + } + } + + processFriendsList(data) { + const queryTag = data.substring(data.indexOf("") + 42, data.indexOf("")); + const items = queryTag.split(""); + + for(const item of items) { + if(!item) continue; + + const puuid = item.substr(11, 36); + + // riot ID + const idTagIndex = item.indexOf("", taglineIndex)); + + this.riotPUUIDToUsername[puuid] = `${username}#${tagline}`; + + // lol summoner name + const lolTagIndex = item.indexOf(" -1) { + const lolNameIndex = item.indexOf("name=", lolTagIndex) + 6; + const lolName = item.substring(lolNameIndex, item.indexOf("'", lolNameIndex)); + + this.riotPUUIDToSummonerName[puuid] = lolName; + } + } + this.log(this.riotPUUIDToUsername); + this.log(this.riotPUUIDToSummonerName); + } + + getSettings(discordUserList, discordUsersDatalist) { + // enabled switch + const enabledSwitch = SettingsBuilder.enabledSwitch(this); + + // cookies textbox + const textboxChange = (value) => { + this.cookies = value; + if(this.enabled) { + SettingsBuilder.toggleEnabledSwitch(enabledSwitch); + } + } + const buttonClick = () => { + const [success, cookies] = this.getCookiesFromLauncher(); + if(success) { + this.cookies = cookies; + cookiesTextbox.children[0].children[1].children[0].value = cookies; + } else { + err(cookies); + } + } + const cookiesTextbox = SettingsBuilder.textboxWithButton("Auth Cookies", "Your auth.riotgames.com cookies. Will only work if you're currently logged in with 'Remember me', and until you log out. Otherwise, use this guide.", + this.cookies, textboxChange, {}, "Fetch cookies from launcher", buttonClick); + setTimeout(() => { + cookiesTextbox.children[0].children[1].children[0].removeAttribute("maxlength"); + cookiesTextbox.children[0].children[2].innerHTML = "Your auth.riotgames.com cookies. Will only work if you're currently logged in with 'Remember me', and until you log out. Otherwise, use this guide." + }, 50); + + const usersList = { + idToName: this.riotPUUIDToUsername, + nameToId: {} + } + for(const [puuid, username] of Object.entries(this.riotPUUIDToUsername)) { + usersList.nameToId[username] = puuid; + } + const datalist = SettingsBuilder.createDatalist("riot", Object.keys(usersList.nameToId)); + const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, usersList, discordUserList, this.discordToRiotPUUIDs, + "If you see numbers instead of names, it's either because the friends list hasn't loaded, or you are no longer friends with them.", "Riot ID", /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/); + + const debugSwitch = SettingsBuilder.debugSwitch(this); + + return SettingsBuilder.settingsPanel(this, enabledSwitch, cookiesTextbox, userMapDiv, debugSwitch); + } + } + + /**************** + ** VALORANT ** + ****************/ + + const valRpcAppID = "811469787657928704"; // https://github.com/colinhartigan/valorant-rpc + + class Valorant { + constructor(riotPUUIDToUsername, log, updateUser) { + this.riotPUUIDToUsername = riotPUUIDToUsername; + this.log = log; + this.updateUser = updateUser; + + // constants + // todo get these from valorant-api + this.gamemodes = { + "newmap": "New Map", + "competitive": "Competitive", + "unrated": "Unrated", + "spikerush": "Spike Rush", + "deathmatch": "Deathmatch", + "ggteam": "Escalation", + "onefa": "Replication", + "snowball": "Snowball Fight", + "custom": "Custom", + "": "Custom" + } + this.maps = { + "Triad": "Haven", + "Duality": "Bind", + "Bonsai": "Split", + "Port": "Icebox", + "Ascent": "Ascent", + "Foxtrot": "Breeze", + "Canyon": "Fracture", + "Pitt": "Pearl", + "Range": "The Range" + } + this.ranks = [ + 'UNRANKED', 'Unused1', 'Unused2', + 'IRON 1', 'IRON 2', 'IRON 3', + 'BRONZE 1', 'BRONZE 2', 'BRONZE 3', + 'SILVER 1', 'SILVER 2', 'SILVER 3', + 'GOLD 1', 'GOLD 2', 'GOLD 3', + 'PLATINUM 1', 'PLATINUM 2', 'PLATINUM 3', + 'DIAMOND 1', 'DIAMOND 2', 'DIAMOND 3', + 'IMMORTAL 1', 'IMMORTAL 2', 'IMMORTAL 3', + 'RADIANT', 'Better than Radiant' + ] + + this.presenceCache = {}; + this.assets = {}; + } + + async fetchRpcAssets() { + const assetsReq = await fetch(`https://discord.com/api/v9/oauth2/applications/${valRpcAppID}/assets`); + const assetList = JSON.parse(assetsReq.body); + for(const asset of assetList) { + // format: {"id": "821415501942882324", "type": 2, "name": "splash_icebox_square"} + this.assets[asset.name] = asset.id; + } + } + + processXMLData(data) { + try { + const puuid = data.substr(16, 36); + + // extract valorant presence + const valorantData = Riot.extractDataFromXML(data, "valorant"); + if(valorantData) { + const base64Data = Riot.extractDataFromXML(valorantData, "p"); + const timestamp = parseInt(Riot.extractDataFromXML(valorantData, "s.t")); + try { + const presenceData = JSON.parse(atob(base64Data)); + this.processPresenceData(puuid, presenceData, timestamp); + } catch (e) { + console.error(data); + err("Could not JSON parse Valorant presence data! " + e); + } + } else { + const previousPresence = this.presenceCache[puuid]; + delete this.presenceCache[puuid]; + if(previousPresence) this.updateUser(puuid); + } + } catch (e) { + console.error(data); + err(e); + } + } + + processPresenceData(puuid, presenceData, timestamp) { + this.log(presenceData); + + try { + const parseDate = s => { + const year = parseInt(s.substr(0, 4)), + month = parseInt(s.substr(5, 2)) - 1, + day = parseInt(s.substr(8, 2)), + hour = parseInt(s.substr(11, 2)), + minute = parseInt(s.substr(14, 2)), + second = parseInt(s.substr(17, 2)); + if (year === 1) return timestamp; + return +Date.UTC(year, month, day, hour, minute, second); + } + + const previousPresence = this.presenceCache[puuid]; + const map = presenceData.provisioningFlow === "ShootingRange" ? "Range" : presenceData.matchMap.split('/').pop(); + const username = this.riotPUUIDToUsername[puuid]; + + const getGamemode = () => { + if(presenceData.queueId === "") { + if(previousPresence && previousPresence.details.startsWith("In Queue - ")) return previousPresence.details.substr(11); + return "Custom"; + } + const modeName = this.gamemodes[presenceData.queueId]; + return modeName || presenceData.queueId; + } + /* A bit of backstory: + * On 21/09/2021 Riot removed map data from the presence, so you could no longer + * know which map your friend was playing on. + * Except they forgot to remove it during agent select. So the plugin would store + * the map data when they were in agent select, and "remember" it during the + * actual match, even though Riot didn't send it anymore. + * (This wouldn't work if the plugin was started mid-match or for deathmatch). + * Then on 20/10/2021, they mysteriously put it back. No idea if it's here to stay, + * but having the extra code doesn't hurt :) + */ + const getMapName = () => { + const mapName = this.maps[map] || map; + if(mapName) return mapName; + else { + // no map data, get map name from previous presence + if(previousPresence) { + const previousMapName = previousPresence.assets.large_text.split(" | ")[0]; + if(previousMapName !== "No map data" && previousMapName !== "Lobby") + return previousMapName; + } + } + return "No map data"; + } + const getMapIcon = () => { + if(map === "" && presenceData.sessionLoopState === "INGAME") { + // no map data, get map icon from previous presence + if(previousPresence && previousPresence.assets.large_image !== this.assets["game_icon"] && previousPresence.assets.large_image !== this.assets["game_icon_yellow"]) + return previousPresence.assets.large_image; + } + const mapCodename = this.maps[map]; + if(!mapCodename) return this.assets["game_icon_white"]; + return this.assets[`splash_${map === "Range" ? "range" : mapCodename.toLowerCase()}_square`]; + } + const getPartyText = () => `${presenceData.partyAccessibility === "OPEN" ? "Open" : "Closed"} Party${presenceData.isPartyOwner && presenceData.partySize > 1 ? " Leader" : ""}`; + const getStartTimestamp = () => { + if(presenceData.sessionLoopState === "MENUS") { + if(presenceData.partyState === "MATCHMAKING") + return parseDate(presenceData.queueEntryTime); // in queue, return start queue time + if(previousPresence && (previousPresence.details.startsWith("Lobby - ") || previousPresence.details === "Setting Up Custom Game")) + return previousPresence.timestamps.start; + return timestamp; + } else if(presenceData.sessionLoopState === "INGAME") { + if(previousPresence && previousPresence.details.startsWith("Custom")) + return previousPresence.timestamps.start; + if(presenceData.provisioningFlow === "ShootingRange") { + if(previousPresence && previousPresence.provisioningFlow === "ShootingRange") + return previousPresence.timestamps.start; + return timestamp; + } + } + + return parseDate(presenceData.queueEntryTime) || timestamp; + } + + const presenceBoilerplate = { + application_id: valRpcAppID, + name: "VALORANT", + type: 0, + state: getPartyText(), + party: { + id: presenceData.partyId, + size: [presenceData.partySize, presenceData.maxPartySize || (previousPresence ? previousPresence.party.size[1]: 69)] + }, + timestamps: { + start: getStartTimestamp() + }, + assets: { + small_image: this.assets["rank_" + presenceData.competitiveTier] || this.assets["rank_0"], + small_text: `${this.ranks[presenceData.competitiveTier]}${presenceData.leaderboardPosition > 0 ? ` #${presenceData.leaderboardPosition}` : ""} | LVL ${presenceData.accountLevel}` + }, + username: username, + priority: Priorities.PLAYING + }; + let presence = {}; + + switch (presenceData.sessionLoopState) { + case "MENUS": + // in lobby or in queue + const menusGetDetails = () => { + if(presenceData.partyState === "DEFAULT") return `Lobby - ${getGamemode()}`; + if(presenceData.partyState === "MATCHMAKING") return `In Queue - ${getGamemode()}`; + if(presenceData.partyState === "MATCHMADE_GAME_STARTING") return `Match Found - ${getGamemode()}`; + if(presenceData.partyState === "CUSTOM_GAME_SETUP") return `Setting Up Custom Game`; + return `${presenceData.partyState} (?) - ${getGamemode()}`; + } + const menusGetLargeImage = () => { + if(presenceData.isIdle) return this.assets["game_icon_yellow"]; + if(presenceData.partyState === "CUSTOM_GAME_SETUP") return getMapIcon(presenceData.matchMap); + if(presenceData.playerCardId) return formatAsset(`https://media.valorant-api.com/playercards/${presenceData.playerCardId}/smallart.png`); + return this.assets["game_icon"]; + } + const menusGetLargeText = () => (presenceData.isIdle ? "Away" : "Lobby"); + + presence = { + ...presenceBoilerplate, + details: menusGetDetails(), + assets: { + ...presenceBoilerplate.assets, + large_image: menusGetLargeImage(), + large_text: menusGetLargeText(), + }, + priority: presenceData.isIdle ? Priorities.IN_LOBBY_AFK : Priorities.IN_LOBBY + } + break; + case "PREGAME": + // in agent select + const pregameGetDetails = () => { + if(presenceData.provisioningFlow === "Invalid") return `${getGamemode()} - Match Found`; + if(presenceData.provisioningFlow === "CustomGame") return `Custom Game - Agent Select`; + if(presenceData.provisioningFlow === "Matchmaking") return `${getGamemode()} - Agent Select`; + return `${getGamemode()} - ${presenceData.provisioningFlow} (?)`; + } + + presence = { + ...presenceBoilerplate, + details: pregameGetDetails(), + assets: { + ...presenceBoilerplate.assets, + large_image: getMapIcon(), + large_text: (presenceData.isIdle ? "Away" : getMapName()) + } + } + + if(pregameGetDetails().endsWith("Match Found")) // match found 5sec timer + presence.timestamps.end = timestamp + 5000; + else if(pregameGetDetails().endsWith("Agent Select")) // start 75sec countdown once agent select has loaded (~79sec including loading time) + presence.timestamps.end = timestamp + 79000; + break; + case "INGAME": + const ingameGetDetails = () => { + const gamemode = getGamemode(); + if(presenceData.partyState === "MATCHMADE_GAME_STARTING") { // deathmatch skips pregame + presenceBoilerplate.timestamps.end = timestamp + 5000; + return `Match Found - ${gamemode}`; + } + + let s = ""; + + if(gamemode === "Custom" && presenceData.customGameTeam === "TeamSpectate") + s += "Spectating "; + + if(map === "Range") + s += presenceData.provisioningFlow === "NewPlayerExperience" ? "Tutorial" : "The Range"; + else + s += gamemode; + + if(presence.partyOwnerMatchCurrentTeam === "") + s += " - Loading"; + else if(map !== "Range") + s += ` ${presenceData.partyOwnerMatchScoreAllyTeam}-${presenceData.partyOwnerMatchScoreEnemyTeam}`; + + return s; + } + presence = { + ...presenceBoilerplate, + details: ingameGetDetails(), + assets: { + ...presenceBoilerplate.assets, + large_image: getMapIcon(), + large_text: (presenceData.isIdle ? "Away" : getMapName()) // + (username ? " | " + username : ""), + } + } + break; + } + + this.presenceCache[puuid] = presence; + this.log(presence); + + if(!previousPresence || presence.priority !== previousPresence.priority) { + this.updateUser(puuid); + } + } catch (e) { + console.error(puuid, presenceData, timestamp); + err(e); + } + } + + getPresence(puuid) { + return this.presenceCache[puuid]; + } + + deleteAllPresences() { + for(const puuid in this.presenceCache) { + if(this.presenceCache[puuid]) { + delete this.presenceCache[puuid]; + this.updateUser(puuid); + } + } + } + } + + + /*********** + ** LOL ** + ***********/ + + const lolShowSkinName = true; + + const lolRpcAppId = "899030985855860756"; + + class Lol { + constructor(riotPUUIDToUsername, riotPUUIDToSummonerName, log, updateUser) { + this.riotPUUIDToUsername = riotPUUIDToUsername; + this.riotPUUIDToSummonerName = riotPUUIDToSummonerName; + + this.gameVersion = ""; // will be overridden by lolGetLatestVersion() + this.champions = {}; + this.skins = {}; + this.assets = { // to be filled in by fetchRpcAssets() + logo_lol: "899053983879008266", + logo_tft: "899054046294462494", + champions: {}, + ranks: {} + } + this.queues = { // manually adding in gamemodes not in riot json + 1130: { + "queueId": 1130, + "map": "Convergence", + "description": "Teamfight Tactics Hyper Roll", + "notes": "Added in manually" + }, + 1150: { + "queueId": 1150, + "map": "Convergence", + "description": "Teamfight Tactics Double Up", + "notes": "Added in manually" + }, + 1160: { + "queueId": 1160, + "map": "Convergence", + "description": "Teamfight Tactics Double Up (Workshop)", + "notes": "Added in manually" + } + } + this.maps = {}; + + this.presenceCache = {}; + this.log = log; + this.updateUser = updateUser; + } + + processXMLData(data) { + try { + const puuid = data.substr(16, 36); + + // extract lol presence + const lolData = Riot.extractDataFromXML(data, "league_of_legends"); + if(lolData) { + const presenceUnparsed = Riot.extractDataFromXML(lolData, "p"); + if (presenceUnparsed) { + // regalia is an object within the object, causes issues with parser + const presenceHalfParsed = presenceUnparsed.replace(/"/g, '"').replace(/'/g, '"').replace(/"regalia":.+}",/, ""); + try { + const presenceData = JSON.parse(presenceHalfParsed); + const timestamp = Riot.extractDataFromXML(lolData, "s.t"); + const status = Riot.extractDataFromXML(lolData, "st"); + this.log(presenceData); + this.processPresenceData(puuid, presenceData, status, timestamp); + } catch(e) { + console.error(data); + err("Could not JSON parse Lol presence data!" + e); + } + } + } else { + const previousPresence = this.presenceCache[puuid]; + delete this.presenceCache[puuid]; + if(previousPresence) this.updateUser(puuid); + } + } catch (e) { + console.error(data); + err(e); + } + } + + async fetchGameVersion() { + const versionsReq = await fetch("https://ddragon.leagueoflegends.com/api/versions.json"); + this.gameVersion = JSON.parse(versionsReq.body)[0]; + } + + async fetchChampionData() { + const championsReq = await fetch(`https://ddragon.leagueoflegends.com/cdn/${this.gameVersion}/data/en_US/champion.json`); + const champsData = JSON.parse(championsReq.body); + for(const champ of Object.values(champsData.data)) { + this.champions[champ.key] = {name: champ.name, id: champ.id}; + } + } + + async fetchRpcAssets() { + const assetsReq = await fetch(`https://discord.com/api/v9/oauth2/applications/${lolRpcAppId}/assets`); + const assetList = JSON.parse(assetsReq.body); + for(const asset of assetList) { + // format: {"id": "403244850960662538", "type": 2, "name": "aatrox"} + if(asset.name === "logo") this.assets.logo_lol = asset.id; + else if(asset.name === "tft_logo") this.assets.logo_tft = asset.id; + else if(asset.name.startsWith("rank_")) this.assets.ranks[asset.name.substr(5)] = asset.id; + else this.assets.champions[asset.name] = asset.id; + } + } + + async fetchQueueTypes() { + const queueTypesReq = await fetch("https://static.developer.riotgames.com/docs/lol/queues.json"); + const queueTypes = JSON.parse(queueTypesReq.body); + for(const queue of queueTypes) { + if(queue.description) queue.description = queue.description.replace(" games", ""); + this.queues[queue.queueId] = queue; + } + } + + async fetchMapsData() { + const mapsReq = await fetch("https://static.developer.riotgames.com/docs/lol/maps.json"); + const maps = JSON.parse(mapsReq.body); + for(const map of maps) { + this.maps[map.mapId.toString()] = { + name: map.mapName, + notes: map.notes + }; + } + } + + fetchData() { + this.fetchGameVersion().then(() => { + this.fetchRpcAssets(); + this.fetchMapsData(); + this.fetchChampionData(); + this.fetchQueueTypes(); + }); + } + + async getSkinName(champ_id, skin_id) { + // doesn't work with chromas atm + if(!lolShowSkinName) return null; + + if(!this.skins[champ_id]) { + const req = await fetch(`https://ddragon.leagueoflegends.com/cdn/${this.gameVersion}/data/en_US/champion/${champ_id}.json`); + this.skins[champ_id] = JSON.parse(req.body).data[champ_id].skins; + } + + const skins = this.skins[champ_id]; + + for(const skin of Object.values(skins)) { + if(skin.id === skin_id) { + if(skin.name === "default") return null; + return skin.name; + } + } + } + + async processPresenceData(puuid, data, status, timestamp) { + try { + const username = this.riotPUUIDToSummonerName[puuid] || this.riotPUUIDToUsername[puuid]; + timestamp = Math.max(data.timeStamp, timestamp) || data.timeStamp || timestamp; + + let gamemode, map; + if(data.mapId in this.maps) map = this.maps[data.mapId].name; + if(data.gameQueueType === "PRACTICETOOL") [gamemode, map] = ["Practice Tool", "Summoner's Rift"]; + else if (data.queueId && this.queues[data.queueId]) { + const gamemodeData = this.queues[data.queueId]; + [gamemode, map] = [gamemodeData.description, gamemodeData.map]; + } else if (data.queueId === "-1") gamemode = "Custom"; + else if (data.gameStatus === "outOfGame") gamemode = "In the Lobby" + else gamemode = `(?) ${data.queueId} ${data.gameQueueType} ${data.gameStatus}`; + + const isAway = status === "away"; + + const previousPresence = this.presenceCache[puuid]; + + let presenceBoilerplate = { + application_id: lolRpcAppId, + name: data.gameMode === "TFT" ? "Teamfight Tactics" : "League of Legends", + type: 0, + details: gamemode, + assets: { + large_image: data.gameMode === "TFT" ? this.assets.logo_tft : this.assets.logo_lol, //`url:https://ddragon.leagueoflegends.com/cdn/${this.gameVersion}/img/profileicon/${data.profileIcon}.png`, + large_text: `Level ${data.level} | Mastery ${data.masteryScore}`, + small_image: data.rankedLeagueTier ? this.assets.ranks[data.rankedLeagueTier.toLowerCase()] : null, + small_text: data.rankedLeagueTier ? `${data.rankedLeagueTier} ${data.rankedLeagueDivision}` : null + + }, + timestamps: { + start: timestamp + }, + username: username, + priority: Priorities.PLAYING + }; + let presence; + + // if open party, process party data + if (data.pty) { + const partyData = JSON.parse(data.pty); + presenceBoilerplate.party = { + id: partyData.partyId, + size: [partyData.summoners.length, 5] + } + presenceBoilerplate.state = "Open Party" + } else if(data.gameId) { + presenceBoilerplate.party = { + id: data.gameId, + } + } + + if (data.gameStatus !== "inGame" && ["SCOUTING", "LOCKED_IN"].includes(data.clashTournamentState)) { + presence = { + ...presenceBoilerplate, + details: "Clash", + state: data.clashTournamentState === "SCOUTING" ? "Scouting" : "Locked In" + } + } else if (data.gameStatus === "outOfGame") { + presence = { + ...presenceBoilerplate, + details: isAway ? "Main Menu (away)" : "In the Main Menu", + priority: isAway ? Priorities.IN_LOBBY_AFK : Priorities.IN_LOBBY, + inLobby: true + } + if(previousPresence && previousPresence.inLobby) + presence.timestamps.start = previousPresence.timestamps.start; + } else if (data.gameStatus.startsWith("hosting_")) { + presence = { + ...presenceBoilerplate, + state: isAway ? "In the Lobby (away)" : "In the Lobby", + priority: isAway ? Priorities.IN_LOBBY_AFK : Priorities.IN_LOBBY, + inLobby: true + } + if(data.gameStatus === "hosting_Custom") + presence.details = "Custom"; + if(previousPresence && previousPresence.inLobby) + presence.timestamps.start = previousPresence.timestamps.start; + } else if (data.gameStatus === "inQueue") { + presence = { + ...presenceBoilerplate, + state: "In Queue", + timestamps: { + start: data.timeStamp + } + } + } else if (data.gameStatus === "championSelect") { + presence = { + ...presenceBoilerplate, + state: "In Champion Select" + } + } else if (data.gameStatus === "inGame") { + const champion = this.champions[data.championId]; + const inGameGetLargeImage = () => { + if(champion && this.assets.champions[champion.id.toLowerCase()]) + return this.assets.champions[champion.id.toLowerCase()]; + return presenceBoilerplate.assets.large_image; + } + const inGameGetLargeText = async () => { + return await this.getSkinName(champion.id, data.skinVariant) || champion.name; + } + presence = { + ...presenceBoilerplate, + state: "In Game" + (map ? ` (${map})` : ""), + timestamps: { + start: data.timeStamp + }, + assets: { + ...presenceBoilerplate.assets, + large_image: champion ? inGameGetLargeImage() : presenceBoilerplate.assets.large_image, + large_text: champion ? await inGameGetLargeText() : presenceBoilerplate.assets.large_text + } + } + } else { + console.error(data); + console.error("Unknown LoL gameStatus! " + data.gameStatus); + } + + + if (presence) { + this.presenceCache[puuid] = presence; + this.log(presence); + + if(!previousPresence || presence.priority !== previousPresence.priority) { + this.updateUser(puuid); + } + } + } catch (e) { + console.error(puuid, data, timestamp) + err(e); + } + } + + getPresence(puuid) { + return this.presenceCache[puuid]; + } + + deleteAllPresences() { + for(const puuid in this.presenceCache) { + if(this.presenceCache[puuid]) { + delete this.presenceCache[puuid]; + this.updateUser(puuid); + } + } + } + } + + + /***************** + ** WILD RIFT ** + *****************/ + + class WildRift { + constructor(riotPUUIDToUsername, riotPUUIDToSummonerName, log, updateUser) { + this.riotPUUIDToUsername = riotPUUIDToUsername; + this.riotPUUIDToSummonerName = riotPUUIDToSummonerName; + this.presenceCache = {}; + this.log = log; + this.updateUser = updateUser; + } + + processXMLData(data) { + try { + const puuid = data.substr(16, 36); + + // extract wild rift presence + const wrData = Riot.extractDataFromXML(data, "wildrift"); + if(wrData) { + this.log(wrData); + try { + const timestamp = parseInt(Riot.extractDataFromXML(wrData, "s.t")); + const username = Riot.extractDataFromXML(data, "m"); + this.processPresenceData(puuid, timestamp, username); + } catch (e) { + console.error(wrData); + err("Could not parse Wild Rift presence data!" + e); + } + } else { + const previousPresence = this.presenceCache[puuid]; + delete this.presenceCache[puuid]; + if(previousPresence) this.updateUser(puuid); + } + } catch (e) { + console.error(data); + err(e); + } + } + + processPresenceData(puuid, timestamp, username) { + username = username || this.riotPUUIDToSummonerName[puuid] || this.riotPUUIDToUsername[puuid]; + + let presence = { + application_id: customRpcAppId, + name: "League of Legends: Wild Rift", + type: 0, + assets: { + large_image: "889119952911630386", + large_text: "Playing as " + username + }, + timestamps: { + start: timestamp + }, + username: username, + priority: Priorities.PLAYING + }; + + const previousPresence = this.presenceCache[puuid]; + + this.presenceCache[puuid] = presence; + this.log(presence); + + if(!previousPresence) { + this.updateUser(puuid); + } + } + + getPresence(puuid) { + return this.presenceCache[puuid]; + } + + deleteAllPresences() { + for(const puuid in this.presenceCache) { + if(this.presenceCache[puuid]) { + delete this.presenceCache[puuid]; + this.updateUser(puuid); + } + } + } + } + + + /************ + ** EPIC ** + ************/ + + class Epic extends Platform { + constructor() { + super("epic"); + + this.presenceCache = {}; + this.statusCache = {}; + + this.epicIdToDisplayName = {}; + + this.fortniteGamemodes = {}; + this.fortniteRpcAppId = "432980957394370572"; + + this.loadData(); + if(this.enabled) { + this.start(); + } + } + + async start() { + if(!this.authData.refresh) { + return this.destroy(); + } + const success = await this.authenticate(); + if(success) { + await Promise.all([this.fetchFortniteGamemodes(), this.fetchFortniteAssets()]); + this.establishXMPPConnection(this.authData.token); + } else { + this.destroy(); + } + } + + serializeData() { + return { + enabled: this.enabled || false, + authData: this.authData || {}, + usersMap: this.discordToEpicID || {}, + debug: this.debug || false + } + } + + deserializeData(data) { + this.enabled = data.enabled || false; + this.authData = data.authData || {}; + this.discordToEpicID = data.usersMap || {}; + this.debug = data.debug || false; + } + + getPresence(discord_id) { + return super.getPresence(discord_id, this.discordToEpicID, this.presenceCache); + } + + destroy(pluginShutdown) { + this.enabled = false; + for(const id in this.presenceCache) this.deletePresence(id); + clearTimeout(this.refreshTimeout); + clearInterval(this.reconnectInterval); + clearTimeout(this.heartbeat); + if(this.socket) { + try { + this.socket.send(""); + } catch(e) {console.error("Error while sending epic disconnect signal!", e)} + this.socket.close(1000); + } + if(!pluginShutdown) this.saveData(); + } + + restart() { + this.destroy(); + setTimeout(() => { + this.enabled = true; + this.saveData(); + this.start(); + }, 500); // sockets take a while to close + } + + async fetchFortniteGamemodes() { + const req = await fetch("https://fortnite-api.com/v1/playlists"); // big thanks to Officer and his excellent API + const json = JSON.parse(req.body); + for(const gamemode of json.data) { + this.fortniteGamemodes[gamemode.id.toLowerCase()] = { + name: gamemode.name === "CREATIVE MATCHMAKING" ? "Creative (Fill)" : gamemode.name || gamemode.id, // oh epic... + subName: gamemode.subName, + maxSquadSize: gamemode.maxSquadSize, + maxPlayers: gamemode.maxPlayers, + smallIcon: gamemode.images.missionIcon + } + } + + // arena duos has null name/subname for some reason + this.fortniteGamemodes["playlist_showdownalt_duos"] = { + name: "Arena", + subName: "Duos", + maxSquadSize: 2, + maxPlayers: 100, + smallIcon: "https://fortnite-api.com/images/playlists/playlist_showdownalt_duos/missionicon.png" + } + } + + async fetchFortniteAssets() { + const req = await fetch(`https://discord.com/api/v9/oauth2/applications/${this.fortniteRpcAppId}/assets`); + const json = JSON.parse(req.body); + for(const asset of json) { + if(asset.name === "fortnite") this.fortniteLogoAssetId = asset.id; + } + } + + decodeJWT(token) { + return JSON.parse(atob(token.split("~", 2)[1].split(".", 2)[1])); + } + + getID(token) { + return this.decodeJWT(token).sub; + } + + getDisplayName(token) { + return this.decodeJWT(token).dn; + } + + getTokenExpiry(token) { + return this.decodeJWT(token).exp * 1000; + } + + async authenticate() { + // returns true or false based on success + if(this.authData.refresh) { + if(this.authData.token) { + const expiry = this.getTokenExpiry(this.authData.token); + this.log("Token expiring at " + expiry); + if(expiry - new Date() > 300_000) { + // fetch friends list to see if token is valid + const success = await this.fetchFriendsList(); + if(success) { + // use the token + this.setupRefreshTimeout(); + return true; + } + } + } + + const refreshSuccess = await this.refreshToken(); + if(!refreshSuccess) return false; + + this.setupRefreshTimeout(); + return true; + } + + return false; + } + + async refreshToken() { + this.log("Refreshing token..."); + + // check refresh token is still valid + const refreshExpiry = this.decodeJWT(this.authData.refresh).exp * 1000; + if(refreshExpiry - new Date() < 5000) { + this.authData.refresh = null; + BdApi.alert("Your refresh token has expired! Please generate a new one."); + return false; + } + + const req = await fetch("https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token", { + method: "POST", + headers: { + Authorization: "basic MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=", + "Accept-Language": "en-EN", + "Content-Type": "application/x-www-form-urlencoded" + }, + body: `grant_type=refresh_token&refresh_token=${this.authData.refresh}&token_type=eg1&includePerms=false` + }); + const authData = JSON.parse(req.body); + if(authData.error) { + console.error(authData); + + const r = this.authData.refresh; + const errorMessage = r.length > 20 ? authData.errorMessage.replace(r, r.substr(0, 10) + "..." + r.substr(r.length - 10)) : r; + + err("EPIC: Could not refresh token! You may need to login again. " + errorMessage); + return false; + } + + this.authData = { + token: authData.access_token, + refresh: authData.refresh_token + } + this.saveData(); + + this.log("Refreshed token as " + authData.displayName); + + return true; + } + + async redeemExchangeCode(exchangeCode) { + // returns true or false based on success + const req = await fetch("https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token", { + method: "POST", + headers: { + Authorization: "basic MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=", + "Accept-Language": "en-EN", + "Content-Type": "application/x-www-form-urlencoded" + }, + body: `grant_type=exchange_code&exchange_code=${exchangeCode}&token_type=eg1&includePerms=false` + }); + + const authData = JSON.parse(req.body); + if(authData.error) { + err("EPIC: Could not redeem exchange code! " + authData.errorMessage); + return false; + } + this.log("Redeemed exchange code as " + authData.displayName); + + this.authData = { + token: authData.access_token, + refresh: authData.refresh_token + } + return true; + } + + async redeemAuthCode(authCode) { + // get auth code -> https://www.epicgames.com/id/api/redirect?clientId=34a02cf8f4414e29b15921876da36f9a&responseType=code + // returns true or false based on success + const req = await fetch("https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token", { + method: "POST", + headers: { + Authorization: "basic MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=", + "Accept-Language": "en-EN", + "Content-Type": "application/x-www-form-urlencoded" + }, + body: `grant_type=authorization_code&code=${authCode}&token_type=eg1&includePerms=false` + }); + + const authData = JSON.parse(req.body); + if(authData.error) { + err("Could not redeem auth code! " + authData.errorMessage); + return false; + } + this.log("Redeemed auth code as " + authData.displayName); + + this.authData = { + token: authData.access_token, + refresh: authData.refresh_token + } + + this.restart(); + + return true; + } + + setupRefreshTimeout() { + const tokenExpiry = this.decodeJWT(this.authData.token).exp * 1000; + this.refreshTimeout = setTimeout(this.refreshToken.bind(this), tokenExpiry - new Date() - 300_000); + } + + async fetchFriendsList() { + this.epicIdToDisplayName[this.getID(this.authData.token)] = this.getDisplayName(this.authData.token); + + // get friends list + const req = await fetch(`https://friends-public-service-prod.ol.epicgames.com/friends/api/v1/${this.getID(this.authData.token)}/summary?displayNames=true`, { + headers: { + Authorization: "Bearer " + this.authData.token + } + }); + const json = JSON.parse(req.body); + + if(req.statusCode !== 200) { + console.error(req); + console.error(this.authData); + + if(req.statusCode.toString().startsWith('5')) { + console.error(`Error ${req.statusCode} when trying to fetch friends list! Retrying in 5 seconds...`); + await new Promise(r => setTimeout(r, 5000)); + return await this.fetchFriendsList() + } else if(req.statusCode !== 401) { + err("Error while fetching Epic friends!"); + } + + return false; + } + + // get username of friends + // we can request in batches of 100, partition the list of IDs + const friendIDs = json.friends.map(friend => friend.accountId); + const reqs = []; + for (let i = 0; i < friendIDs.length; i += 100) { + const partition = friendIDs.slice(i, i + 100); + const req = fetch("https://account-public-service-prod.ol.epicgames.com/account/api/public/account?accountId=" + partition.join("&accountId="), { + headers: { + Authorization: "Bearer " + this.authData.token + } + }); + reqs.push(req); + } + + for(const req of reqs) { + const reqResult = await req; + if(reqResult.statusCode !== 200) { + console.error(reqResult); + err("Error while fetching Epic friends partition!"); + continue; + } + + const json = JSON.parse(reqResult.body); + for(const friend of json) { + this.epicIdToDisplayName[friend.id] = friend.displayName; + } + } + + this.log(this.epicIdToDisplayName); + return true; + } + + // xmpp stuff + establishXMPPConnection(token) { + try { + this.presenceCache = {}; + + const address = "xmpp-service-prod.ol.epicgames.com"; + const accountId = this.getID(token); + + const messages = [ + ``, '', + `${btoa("\0" + accountId + "\0" + token)}`, + ``, '', + `V2:launcher:WIN::`, + `` + ] + + const sock = new WebSocket(`wss://${address}`, "xmpp"); + sock.onopen = () => { + try { + this.log("Connected!"); + + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + + sendNext(); + this.heartbeat = setInterval(sendPing, 60_000); + } catch (e) { + err(e); + } + }; + this.socket = sock; + + const send = (data, log=true) => { + if(!data || !this.enabled) return; + try { + if(sock.readyState === 1) { + sock.send(data); + if(log) this.log("-> " + data); + } + } catch (e) { + err(e); + } + } + + const sendNext = () => send(messages.shift()); + + let lastPing = 0; + const sendPing = () => { + if(lastPing > 0 && Date.now() - lastPing > 150_000) { + this.log("Haven't received a ping back in 150sec, reconnecting..."); + return this.restart(); + } + + send("", false); + } + + sock.onmessage = event => { + try { + // const timestamp = performance.timeOrigin + event.timeStamp + const data = event.data; + + if(data.includes("acbeabf8-b04b-4e94-a044-6d6b8f04514e")) + lastPing = Date.now(); + else + this.log("<- " + data); + + if(data.startsWith(" 0) sendNext(); + + // process data + if(data.startsWith(" { + if(!this.enabled || sock !== this.socket) return this.log("Websocket disconnected!"); + console.error("Epic disconnected! Retrying in 5 seconds..."); + + if(this.reconnectInterval) return; + + this.reconnectInterval = setInterval(() => { + this.log("Reconnecting..."); + this.establishXMPPConnection(token); + }, 5000); + + clearTimeout(this.heartbeat); + }; + } catch (e) { + console.error(token); + err(e); + } + } + + processPresence(presence) { + const idStart = presence.indexOf("from=\"") + 6; + const idEnd = presence.indexOf("@", idStart); + const id = presence.substring(idStart, idEnd); + + const fullIdEnd = presence.indexOf("\"", idStart); + const fullId = presence.substring(idStart, fullIdEnd); + const presenceSource = fullId.split(":")[1]; + + let show; + const showStart = presence.indexOf("") + 6; + if(showStart > 5) { + const showEnd = presence.indexOf("", showStart); + show = presence.substring(showStart, showEnd); + } + + const statusStart = presence.indexOf("") + 8; + if(statusStart === 7) return; + const statusEnd = presence.indexOf("", statusStart); + const status_raw = presence.substring(statusStart, statusEnd); + const status = JSON.parse(status_raw); + this.log(status); + + let presenceType; + if(presence.includes("type=")) { + const presenceTypeStart = presence.indexOf("type=") + 6; + const presenceTypeEnd = presence.indexOf("\"", presenceTypeStart); + presenceType = presence.substring(presenceTypeStart, presenceTypeEnd); + + } + + let timestamp; + if(presence.includes(" in game + if(previousPresence.details !== currentStatus) presence.timestamps.start = + new Date(); + } + + break; + case "Fortnite": + try { + const gamemode = this.fortniteGamemodes[(status.Properties.GamePlaylistName_s || "").toLowerCase()] || {name: status.Properties.GamePlaylistName_s || "", maxSquadSize: 0, maxPlayers: 100}; + const partyData = Object.keys(status.Properties).filter(s => s.startsWith("party.joininfodata"))[0]; + + const detailsTemplate = (name, subName) => name + (subName ? ` (${subName})` : ""); + const stateTemplate = (kills, players, maxPlayers=100) => `๐Ÿ’€ ${kills} โ€‰๐Ÿ‘ค ${players}/${maxPlayers}`; // thin space because discord removes double spaces + + let details, state; + if(status.bIsPlaying) { + const kills = parseInt(status.Properties.FortGameplayStats_j.numKills); + if(status.bIsJoinable) { // creative + details = detailsTemplate(gamemode.name || "Creative", gamemode.subName); + + if(status.Properties.ServerPlayerCount_i) state = stateTemplate(kills, status.Properties.ServerPlayerCount_i, gamemode.maxPlayers || 16); + else state = "Loading"; + } else if(status.Properties.GamePlaylistName_s) { // battle royale + details = detailsTemplate(gamemode.name || status.Properties.GamePlaylistName_s, gamemode.subName); + + if(status.Properties.ServerPlayerCount_i) state = stateTemplate(kills, status.Properties.ServerPlayerCount_i); + else state = `Loading`; + } + if(status.Properties.FortGameplayStats_j.bFellToDeath) state += " - Died of fall damage"; + } else if (status.Properties.FortGameplayStats_j) { // in the lobby, hasn't launched a gamemode yet + if(gamemode.name) details = detailsTemplate(gamemode.name || status.Properties.GamePlaylistName_s, gamemode.subName); + if(show === "xa" || show === "away") state = "Away - In the Lobby"; + else state = "In the Lobby"; + } else { // game loading + details = "Loading"; + } + + const partySize = status.Properties.FortPartySize_i || status.Properties.Event_PartySize_s; + const maxPartySize = gamemode.maxSquadSize || (partySize <= 4 ? 4 : 16); + + presence = { + ...presenceBoilerplate, + application_id: this.fortniteRpcAppId, + name: "Fortnite", + details: details, + state: state, + assets: { + large_image: this.fortniteLogoAssetId, + small_image: formatAsset(gamemode.smallIcon), + small_text: gamemode.subName + }, + party: { + id: status.SessionId, + size: [partySize, maxPartySize] + }, + timestamps: { + start: timestamp + }, + priority: Priorities.PLAYING, + gameId: status.SessionId + } + + if(previousPresence.gameId === presence.gameId) + presence.timestamps.start = previousPresence.timestamps.start; + + if(partyData) { + const partyInfo = status.Properties[partyData]; + if(partyInfo.sourceDisplayName) presence.username = partyInfo.sourceDisplayName; + if(partyInfo.partyId) presence.party.id = partyInfo.partyId; + } + } catch(e) { + console.error(presence); + console.error(status); + err(e); + } + + break; + default: // misc + if(presenceType === "unavailable") return this.deletePresence(id); + + let name; + switch(presenceSource) { + case "fghi4567eJdrrwo5Dgu1RiO2R0vM1XVK": + name = "Satisfactory"; + break; + case "fcb692f0fdf14526b1ffbb77cf1ef288": + name = "Paladins"; + break; + case "f71b1231985f48d1af3de723e0a6acdd": + name = "Smite"; + break; + case "fghi4567gDK32qevrArU3uezn7r9kY8Y": + name = "Rocket League Sideswipe"; + break; + case "68d2cc08f9a94b8fb51af4f5cfa6d41b": + name = "Grand Theft Auto V"; + break; + default: + if(this.debug) err("Unknown game ID! " + presenceSource); + else console.error("Unknown game ID! " + presenceSource); + return; + } + + presence = { + ...presenceBoilerplate, + name: name, + details: this.statusCache[id], + assets: { + large_image: epicLogoID + }, + timestamps: { + start: timestamp + } + } + } + + this.presenceCache[id] = presence; + this.log(presence); + + if(!previousPresence || presence.name !== previousPresence.name) { + this.updateUser(id, this.discordToEpicID); + } + } + + async getAppName(appID) { + // uses store search. definitely not the best way since it won't work on hidden/unlisted games. + if(appID === "fn") return "Fortnite"; + if(appID === "ue") return "Unreal Engine"; + const req = await fetch("https://www.epicgames.com/graphql", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + query: "query searchStoreQuery($count: Int = 5, $keywords: String, $namespace: String, $category: String) {\n Catalog {\n searchStore(\n count: $count\n keywords: $keywords\n namespace: $namespace\n category: $category\n ) {\n elements {\n title\n namespace\n id\n }\n }\n }\n}\n", + variables: { + "namespace": appID, + "category": "games/edition/base|bundles/games|editors|software/edition/base" + } + }) + }); + + try { + return JSON.parse(req.body).data.Catalog.searchStore.elements[0].title; + } catch(e) { + err("Could not fetch app name from id!", req.body); + } + } + + getSettings(discordUserList, discordUsersDatalist) { + // enabled switch + const enabledSwitch = SettingsBuilder.enabledSwitch(this); + + // cookies textbox + const deleteButtonClick = () => { + this.authData = {}; + SettingsBuilder.getTextboxInput(tokenTextbox).value = ""; + if(this.enabled) SettingsBuilder.toggleEnabledSwitch(enabledSwitch); + this.saveData(); + } + const tokenTextbox = SettingsBuilder.textboxWithButton("Refresh Token", null, this.authData.refresh, null, {placeholder: "Refresh token", disabled: true}, "Delete", deleteButtonClick); + + const codeButtonClick = async (textbox, redeemFunction) => { + const code = SettingsBuilder.getTextboxInput(textbox).value; + if(!code) return; + + const button = textbox.children[0].children[1].children[1]; + button.classList.remove("bd-button-danger"); + button.innerHTML = "Redeeming..."; + + const success = await redeemFunction(code); + + if(success) { + button.innerHTML = "Success!"; + SettingsBuilder.getTextboxInput(tokenTextbox).value = this.authData.refresh; + SettingsBuilder.getTextboxInput(textbox).value = ""; + this.saveData(); + } else { + button.innerHTML = "Retry"; + button.classList.add("bd-button-danger"); + } + } + + const authCodeButtonClick = () => codeButtonClick(authCodeTextbox, this.redeemAuthCode.bind(this)); + const authCodeTextbox = SettingsBuilder.textboxWithButton(null, null, null, null, {placeholder: "Auth code"}, "Redeem auth code", authCodeButtonClick); + + const exchangeCodeButtonClick = () => codeButtonClick(exchangeCodeTextbox, this.redeemExchangeCode.bind(this)); + const exchangeCodeTextbox = SettingsBuilder.textboxWithButton(null, "If you're signed in in your browser, get your auth code here. Otherwise, generate an exchange code.", null, null, {placeholder: "Exchange code"}, "Redeem exchange code", exchangeCodeButtonClick); + setTimeout(() => { + exchangeCodeTextbox.children[0].children[2].innerHTML = `If you're signed in in your browser, get your auth code here. Otherwise, generate an exchange code.` + }, 50); + + // remove spacing between textboxes + setTimeout(() => { + tokenTextbox.children[0].children[3].classList.remove("divider-1Jfi9s"); + authCodeTextbox.children[0].children[3].classList.remove("divider-1Jfi9s"); + exchangeCodeTextbox.children[0].children[3].classList.remove("divider-1Jfi9s"); + }, 50); + + const usersList = { + idToName: this.epicIdToDisplayName, + nameToId: {} + } + for(const [id, username] of Object.entries(this.epicIdToDisplayName)) { + usersList.nameToId[username] = id; + } + const datalist = SettingsBuilder.createDatalist("epic", Object.keys(usersList.nameToId)); + const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, usersList, discordUserList, this.discordToEpicID, + "If you see numbers instead of names, it's either because the friends list hasn't loaded, or you are no longer friends with them.","Epic username", /^.+$/); + + const debugSwitch = SettingsBuilder.debugSwitch(this); + + return SettingsBuilder.settingsPanel(this, enabledSwitch, tokenTextbox, authCodeTextbox, exchangeCodeTextbox, userMapDiv, debugSwitch); + } + } + + /********** + ** EA ** + **********/ + + class EA extends Platform { + + constructor() { + super("ea"); + + this.eaLogoAssetId = "950210006509289492"; + this.apexRpcAppId = "893911040713191444"; // https://github.com/Holfz/ApexRPC + + this.apexAssetNames = { + "Logo": "apex-legends", + "Training": "firing-range", + "World's Edge": "world-edge", + "King's Canyon": "king-canyon", + "Olympus": "olympus", + "Stormpoint": "stormpoint", + "Encore": "encore" + } + + this.presenceCache = {}; + this.friendIdToDisplayName = {}; + this.assetCache = {}; + this.apexAssets = {}; + + this.loadData(); + if(this.enabled) { + this.start(); + } + } + + async start() { + if(!this.remid) return this.destroy(); + this.fetchApexAssets(); + const accessToken = await this.authenticate(this.remid); + if(!accessToken) return; + await Promise.all([this.getOwnUsername(), this.getFriendsList()]); + if(accessToken) await this.connectWebsocket(accessToken); + } + + serializeData() { + return { + enabled: this.enabled || false, + remid: this.remid || "", + usersMap: this.discordToEaIDs || {}, + debug: this.debug || false + } + } + + deserializeData(data) { + this.enabled = data.enabled || false; + this.remid = (data.remid || "").trim(); + this.discordToEaIDs = data.usersMap || {}; + this.debug = data.debug || false; + } + + async fetchApexAssets() { + const req = await fetch(`https://discord.com/api/v9/oauth2/applications/${this.apexRpcAppId}/assets`); + const json = JSON.parse(req.body); + for(const asset of json) { + this.apexAssets[asset.name] = asset.id; + } + } + + async authenticate(remid) { + try { // step 1 + const req1 = await fetch("https://accounts.ea.com/connect/auth?" + + "client_id=JUNO_PC_CLIENT&" + + "response_type=code&" + + "nonce=1&" + + "pc_sign=eyJhdiI6InYxIiwiYnNuIjoiRzJDN002MyIsImdpZCI6Mzk4NzYsImhzbiI6IkFSUkFZMCIsIm1hYyI6IiQwMGZmOWJiNGMyYTAiLCJtaWQiOiI3NTc3MTg0MDY1MDM5NjIzNTkxIiwibXNuIjoiLkcyQzdNNjMuQ05DTUswMDA5NDAwMjYuIiwic3YiOiJ2MSIsInRzIjoiMjAyMi0yLTI3IDEzOjM1OjQyOjI0NiJ9.XPSVI2ksrbveN_FcG2ep_1QpqLphs-cWZRgcsSnIfsI&" + + "", { + headers: { + "Cookie": `remid=${remid}`, + } + }); + this.log(req1); + + if(!req1.headers['set-cookie']) { + BdApi.alert("Could not login to EA! Most likely your RemID is invalid."); + this.destroy(); + return; + } + + const remidHeader = req1.headers['set-cookie'][0]; + remid = remidHeader.split('=')[1].split(';')[0]; + this.log("New RemID: " + remid); + this.remid = remid; + this.saveData(); + + const redirectUrl = req1.headers.location; + const code = redirectUrl.match(/code=(.+)&?/)[1]; + this.log("Auth Code: " + code); + + // step 2 + const req2 = await fetch("https://accounts.ea.com/connect/token", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded" + }, + body: + "grant_type=authorization_code" + + `&code=${code}&` + + "client_id=JUNO_PC_CLIENT&" + + "client_secret=4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te&" + + "" + }); + this.log(req2); + + const authResponse = JSON.parse(req2.body) + this.log(authResponse); + + authResponse.accessTokenExpires = Date.now() + authResponse.expires_in * 1000; + this.auth = authResponse; + return authResponse.access_token; + } catch(e) { + console.error(remid); + err(e); + } + } + + getRemIDFromEaApp() { + // I doubt this works on Mac + const filepath = process.env.LOCALAPPDATA + "/Electronic Arts/EA Desktop/cookie.ini"; + + let fileContents; + try { + fileContents = fs.readFileSync(filepath).toString(); + } catch(e) { + return [false, e.toString() || "Could not read file!"]; + } + + for(const line of fileContents.split('\n')) { + if(line.startsWith("remid=")) { + return [true, line.substring(26).trim()]; + } + } + + return [false, "Could not parse file! Is it corrupt? (Note: EA have started encrypting the cookie.ini file, so this plugin won't work until I find a way to bypass the encryption)"]; + } + + async getOwnUsername() { + this.log("Fetching own username..."); + + const req = await fetch("https://service-aggregation-layer.juno.ea.com/graphql?" + + "operationName=GetPersonas&" + + `variables=${encodeURIComponent(JSON.stringify({overrideCountryCode: ""}))}&` + + `extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: "b81297d11a35ee6e21f5c5582452121dccf7ef54fd60e66218ee7dd199fb310d"}}))}`, { + headers: { + "Authorization": `Bearer ${this.auth.access_token}`, + } + }); + const json = JSON.parse(req.body); + this.log(json); + + this.ownId = json.data.me.id; + this.log("My id is " + this.ownId); + + for(const persona of json.data.me.personas) { + if(persona.namespaceName === "cem_ea_id") + this.ownUsername = persona.displayName; + else if(persona.namespaceName === "discord" && persona.displayName) { + this.log(`My linked discord account is ${persona.displayName}`); + const [username, discriminator] = persona.displayName.split("#"); + if(username && discriminator) { + const user = ZeresPluginLibrary.DiscordModules.UserStore.findByTag(username, discriminator); + if(user && !(user.id in this.discordToEaIDs)) + this.discordToEaIDs[user.id] = [this.ownId]; + } + } + } + + if(this.ownUsername) { + this.log("My username is " + this.ownUsername); + this.friendIdToDisplayName[this.ownId] = this.ownUsername; + } else { + console.error("[EA] Couldn't find my username!"); + } + } + + async getFriendsList() { + this.log("Fetching friends list..."); + + const req = await fetch("https://service-aggregation-layer.juno.ea.com/graphql?" + + "operationName=GetMyFriends&" + + `variables=${encodeURIComponent(JSON.stringify({offset: 0, limit: 99}))}&` + + `extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: "f8856d3ce53eac1d88ee1780851888a25575fad4f142e7b6142c0ace28797baa"}}))}`, { + headers: { + "Authorization": `Bearer ${this.auth.access_token}`, + } + }); + const json = JSON.parse(req.body); + this.log(json); + + for(const friendData of json.data.me.friends.items) { + if(!friendData.player.uniqueName) continue; // ghost friend + this.friendIdToDisplayName[friendData.id] = friendData.player.displayName; + } + + this.log(this.friendIdToDisplayName); + } + + async connectWebsocket(accessToken) { + if(!this.enabled) return; + + const onconnect = () => { + this.log("Connected to WebSocket!"); + + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + + this.messageNumber = 0; + this.login(accessToken); + + this.heartbeat = setInterval(this.sendHeartbeat.bind(this), 10000); + } + const onmessage = this.messageReceived.bind(this); + const onclose = () => { + if(!this.enabled || sock !== this.socket) return this.log("Websocket disconnected!"); + console.error("EA disconnected! Retrying in 5 seconds..."); + + if(this.reconnectInterval) return; + + this.reconnectInterval = setInterval(async () => { + if(this.auth.accessTokenExpires - Date.now() < 10_000) { + accessToken = await this.authenticate(this.remid); + } + + this.log("Reconnecting..."); + this.connectWebsocket(accessToken); + }, 5000); + + clearTimeout(this.heartbeat); + } + + this.socket_id = SimpleSocket.create("wss://rtm.tnt-ea.com:8095/websocket", { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) QtWebEngine/5.15.2 Chrome/83.0.4103.122 Safari/537.36 Origin/10.6.0.00000 EAApp/12.0.179.5090" + } + }, onconnect, onmessage, onclose); + this.sendToSocket = (data) => SimpleSocket.send(this.socket_id, data); + } + + varInt(int) { + const bytes = []; + while (true) { + if ((int & ~0x7F) === 0) { + bytes.push(int); + return Buffer.from(bytes); + } + bytes.push((int & 0x7F) | 0x80); + int >>>= 7; + } + } + + readVarInt (buf, start=0) { + let value = 0; + let i = start; + let currentByte; + + do { + currentByte = buf[i]; + value |= ((currentByte & 0x7F) << (7 * (i - start))); + i++; + } while ((currentByte & 0x80) !== 0); + + return [value, i]; + } + + readString(buf, start) { + // start is the index of the string length, not the string itself + const [length, lengthEnd] = this.readVarInt(buf, start); + const subarray = buf.subarray(lengthEnd, lengthEnd + length); + return [subarray.toString(), lengthEnd + length]; + } + + stringifyBuffer = (buf, step=25) => { + const lines = []; + for(let i = 0; i < buf.length; i += step) { + let line = [...buf.slice(i, i + step)]; + let s = ""; + s += (line.map(n => n.toString(16).toUpperCase().padStart(2, '0')).join(' ')).padEnd(step * 3 - 1, ' '); + s += " | "; + s += line.map(n => 32 <= n && n <= 126 ? String.fromCharCode(n) : '.').join(''); + lines.push(s); + } + return lines.join('\n'); + } + + messageReceived(buf) { + this.log(" <-\n" + this.stringifyBuffer(buf)); + + try { // check if buffer has timestamp + const [bufferLength, bufferLengthEnd] = this.readVarInt(buf, 5); + if(buf[bufferLengthEnd] === 0x0A) { + // there is a timestamp + const [timestampString, timestampStringEnd] = this.readString(buf, bufferLengthEnd + 1); + const timestamp = timestampString.split('-')[2]; + + const packetTypeIndex = timestampStringEnd; + const packetType = buf[packetTypeIndex]; + switch (packetType) { + case 0x7A: + // Auth response + this.log("Got auth response!"); + + const [messageLength1, messageLength1End] = this.readVarInt(buf, packetTypeIndex + 1); + const [messageLength2, messageLength2End] = this.readVarInt(buf, messageLength1End + 1); + + const [ownIdString, ownIdEnd] = this.readString(buf, messageLength2End + 1); + this.ownId = ownIdString.split(':')[1]; + this.log("My id is " + this.ownId); + + this.requestOwnPresence(); + this.requestFriendPresences(); + break; + case 0x92: + // friends list + this.log("Got friends list!"); + break; + case 0x62: + // friend name + this.log("Got friend name!"); + break; + } + } else if(buf[bufferLengthEnd] === 0x3A) { + // there is no timestamp + const [bufferLengthAgain, bufferLengthAgainEnd] = this.readVarInt(buf, bufferLengthEnd + 1); + const [userId, userIdEnd] = this.readString(buf, bufferLengthAgainEnd + 1); + + const packetTypeIndex = userIdEnd; + const packetType = buf[packetTypeIndex]; + switch (packetType) { + case 0x12: + // presence + if(userId === this.ownId) this.log("Received my own presence"); + else this.log("Received presence for user " + userId); + + const [presenceJson, presenceJsonEnd] = this.readString(buf, packetTypeIndex + 1); + const presence = JSON.parse(presenceJson); + this.log(presence); + + if(presence.gameActivity_richPresence) this.log(JSON.parse(presence.gameActivity_richPresence)); + + const [timestamp, timestampEnd] = this.readString(buf, presenceJsonEnd + 1); + + this.processPresence(userId, presence, +new Date(timestamp)); // does this work with timezones? + + break; + case 0x28: + // no presence for user + this.log(`User ${userId} is offline`); + + delete this.presenceCache[userId]; + break; + } + } else { + if( + buf[bufferLengthEnd] !== 0xC2 && // "session changed" + buf[bufferLengthEnd] !== 0x82 && // game invite + buf[bufferLengthEnd] !== 0x72 // unknown, short buffer e.g. "00 00 00 04 0A 02 72 00" + ) { + if(this.debug) err("Neither 0A or 3A after message! " + buf[bufferLengthEnd].toString(16)); + } + } + } catch(e) { + err(e); + } + } + + send(buf, log=true) { + if(log) this.log(" ->\n" + this.stringifyBuffer(buf)); + this.sendToSocket(buf); + } + + encodeString(s) { + const stringLength = this.varInt(s.length); + const stringEncoded = Buffer.from(s, 'utf-8'); + return Buffer.concat([stringLength, stringEncoded]); + } + + createTimestampBuffer() { + const timestamp = Date.now(); + const timestampString = `c-${this.messageNumber++}-${timestamp}-${timestamp}`; + return this.encodeString(timestampString); + } + + formatAndSendBuffer(buf, log=true) { + try { + const timestampBuffer = this.createTimestampBuffer(); + + const totalLength = timestampBuffer.length + buf.length; + const totalLengthVarInt = this.varInt(totalLength + 1); + const totalLength32Bit = Buffer.allocUnsafe(4); + totalLength32Bit.writeUint32BE(totalLength + totalLengthVarInt.length + 2); + + const newline = Buffer.from([0x0A]); + const header = Buffer.concat([totalLength32Bit, newline, totalLengthVarInt, newline]); + + this.send(Buffer.concat([header, timestampBuffer, buf]), log); + } catch(e) { + err(e); + } + } + + login(accessToken) { + try { + const packetType = Buffer.from([0xF2, 0x02]); + + const newline = Buffer.from([0x0A]); + const accessTokenBuffer = this.encodeString(accessToken); + const middleData = Buffer.from([0x10, 0x00, 0x18, 0x00, 0x20, 0x00, 0x2A, 0x06, 0x6F, 0x72, 0x69, 0x67, 0x69, 0x6E, 0x30, 0x04, 0x3A]); + const platformJson = this.encodeString('{"clientType":"ClientWeb","version":"juno-spa-0.0.1-15146-80d8a20","integrations":"LoginV3"}'); + + const restOfPacketLengthBuffer = this.varInt(newline.length + accessTokenBuffer.length + middleData.length + platformJson.length); + + const handshakeBuffer = Buffer.concat([packetType, restOfPacketLengthBuffer, newline, accessTokenBuffer, middleData, platformJson]); + this.formatAndSendBuffer(handshakeBuffer); + } catch(e) { + err(e); + } + } + + sendHeartbeat() { + try { // the function is called in setInterval, so any unhandled error will crash the whole discord + this.formatAndSendBuffer(Buffer.from([0xA2, 0x01, 0x00]), false); + } catch(e) { + err(e); + } + } + + requestOwnPresence() { + const header = Buffer.from([0x12, 0x19, 0x12, 0x17, 0x0A]); + const ownIdBuffer = this.encodeString(this.ownId); + const middleNumber = Buffer.from([0x12]); + const originString = this.encodeString("origin"); + this.formatAndSendBuffer(Buffer.concat([header, ownIdBuffer, middleNumber, originString])); + } + + requestFriendPresences() { + this.formatAndSendBuffer(Buffer.from([0x9A, 0x03, 0x00])); + } + + async processPresence(id, data, timestamp) { + try { + if(data.gameActivity_isNull) return this.deletePresence(id); + if(data.presence_null === undefined) return; // is own status (not presence, just online/away/invisible) + + const presenceData = JSON.parse(data.gameActivity_gamePresence); + const richPresenceData = data.gameActivity_richPresence ? JSON.parse(data.gameActivity_richPresence) : {}; + const gameArtUrl = await this.getGameArt(data.gameActivity_productId) + const presence = { + application_id: customRpcAppId, + name: data.gameActivity_gameTitle, + details: richPresenceData.data, + type: 0, + timestamps: {start: timestamp}, + assets: { + large_image: formatAsset(gameArtUrl) || this.eaLogoAssetId, + large_text: data.gameActivity_gameTitle + }, + username: this.friendIdToDisplayName[id], + priority: Priorities.PLAYING, + session: presenceData.data.session + }; + + const previousPresence = this.presenceCache[id]; + const wasPlayingSameGame = previousPresence && previousPresence.name === presence.name; + const status = richPresenceData.data; + + switch (data.gameActivity_productId) { + case "Origin.OFR.50.0002694": { // Apex Legends + presence.application_id = this.apexRpcAppId; + presence.assets.large_image = this.apexAssets[this.apexAssetNames["Logo"]]; + + let match = status.match(/(.+) - (.+)/); + if(match) { + const map = match[1]; + presence.assets.large_image = this.apexAssets[this.apexAssetNames[map]] || formatAsset(gameArtUrl); + + presence.details = "Playing " + match[2]; // gamemode + match = status.match(/(.+) - (.+) \((.+)\)/); + if(match) { + presence.state = match[3]; // "10 Squads Left" + } + } + + if(wasPlayingSameGame && presence.session === previousPresence.session) { + presence.timestamps = previousPresence.timestamps; + } + break; + } + case "Origin.OFR.50.0004567": { // FIFA 22 + let match = status.match(/^\((.+)\)$/); + if(match) presence.details = match[1]; + + match = status.match(/^(.+) (\d+-\d+ [A-Za-z]{1,3} [V\-] [A-Za-z]{1,3}.+$)/); + if(match) { + presence.details = "Playing " + match[1]; + presence.state = match[2]; + + if(wasPlayingSameGame && previousPresence.state) { + presence.timestamps = previousPresence.timestamps; + } + } + + match = status.match(/^(.+) \((.+)\)$/); + if(match) { + presence.details = match[1]; + presence.state = match[2]; + presence.priority = Priorities.IN_LOBBY; + } + break; + } + default: { + if(previousPresence && status === previousPresence.details) { + presence.timestamps = previousPresence.timestamps; + } + } + } + + this.presenceCache[id] = presence; + this.log(presence); + + if(!previousPresence || presence.name !== previousPresence.name) { + this.updateUser(id, this.discordToEaIDs); + } + } catch(e) { + console.error(id, data, timestamp); + err(e); + } + } + + deletePresence(id) { + if(this.presenceCache[id]) { + delete this.presenceCache[id]; + this.updateUser(id, this.discordToEaIDs); + } + } + + async getGameArt(productId) { + try { + if(this.assetCache[productId]) return this.assetCache[productId]; + + const req = await fetch("https://service-aggregation-layer.juno.ea.com/graphql?" + + "operationName=inGamePresenceData&" + + `variables=${encodeURIComponent(JSON.stringify({locale: "en", offerIds: [productId]}))}&` + + `extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: "6d7316368c350bbf3de50676add3d1d00d73021270f9d365e5a5e388fd9741c8"}}))}`); + const json = await JSON.parse(req.body); + + if(!json.data.gameProducts.items[0].baseItem) return null; + + this.assetCache[productId] = json.data.gameProducts.items[0].baseItem.keyArt.aspect1x1Image.path; + return json.data.gameProducts.items[0].baseItem.keyArt.aspect1x1Image.path; + } catch(e) { + console.error(e); + err("Could not get game art for game " + productId); + } + } + + getPresence(discord_id) { + return super.getPresence(discord_id, this.discordToEaIDs, this.presenceCache); + } + + getSettings(discordUserList, discordUsersDatalist) { + // enabled switch + const enabledSwitch = SettingsBuilder.enabledSwitch(this); + + // cookies textbox + const textboxChange = (value) => { + this.cookies = value; + if(this.enabled) { + SettingsBuilder.toggleEnabledSwitch(enabledSwitch); + } + } + const buttonClick = () => { + BdApi.showConfirmationModal("Warning", "Fetching your RemID will log you out of the EA App. Are you sure?", { + danger: true, + confirmText: "Yes, I'm sure", + onConfirm: getRemID + }); + } + const getRemID = () => { + const button = remidTextbox.children[0].children[1].children[1]; + button.classList.remove("bd-button-danger"); + button.innerHTML = "Fetching..."; + + const [success, remid] = this.getRemIDFromEaApp(); + if(success) { + this.remid = remid; + remidTextbox.children[0].children[1].children[0].value = remid; + button.innerHTML = "Success!"; + this.saveData(); + } else { + const error = remid; + button.classList.add("bd-button-danger"); + if(error.message && error.message.includes("no such file")) { + button.innerHTML = "File not found"; + } else { + button.innerHTML = "Failed"; + console.error(error); + BdApi.alert(error); + } + } + } + const remidTextbox = SettingsBuilder.textboxWithButton("RemID Cookie", "Your EA RemID cookie. Fetching it only works on the new 'EA App', not Origin.", + this.remid, textboxChange, {}, "Fetch RemID from EA App", buttonClick); + + const usersList = { + idToName: this.friendIdToDisplayName, + nameToId: {} + } + for(const [id, username] of Object.entries(this.friendIdToDisplayName)) { + usersList.nameToId[username] = id; + } + const datalist = SettingsBuilder.createDatalist("ea", Object.keys(usersList.nameToId)); + const userMapDiv = SettingsBuilder.userMapInterface(this, datalist, discordUsersDatalist, usersList, discordUserList, this.discordToEaIDs, null, "EA Username", /^\d+$/); + + const debugSwitch = SettingsBuilder.debugSwitch(this); + + return SettingsBuilder.settingsPanel(this, enabledSwitch, remidTextbox, userMapDiv, debugSwitch); + } + + destroy(pluginShutdown) { + this.enabled = false; + for(const id in this.presenceCache) this.deletePresence(id); + clearInterval(this.heartbeat); + clearInterval(this.reconnectInterval); + if(this.socket_id) SimpleSocket.close(this.socket_id, 1001); + if(!pluginShutdown) this.saveData(); + } + } + + + + /************** + ** PLUGIN ** + **************/ + + const platforms = [Riot, Epic, Steam, EA, Minecraft, Twitch]; + + const CrossPlatformPlaying = (() => { + return !global.ZeresPluginLibrary ? class { + constructor() {this._config = config;} + getName() {return config.info.name;} + getAuthor() {return config.info.authors.map(a => a.name).join(", ");} + getDescription() {return config.info.description;} + getVersion() {return config.info.version;} + + load() { + BdApi.showConfirmationModal("Library Missing", `The library plugin needed for ${config.info.name} is missing. Please click Download Now to install it.`, { + confirmText: "Download Now", + cancelText: "Cancel", + onConfirm: () => { + require("request").get("https://rauenzi.github.io/BDPluginLibrary/release/0PluginLibrary.plugin.js", async (error, response, body) => { + if (error) return require("electron").shell.openExternal("https://betterdiscord.net/ghdl?url=https://raw.githubusercontent.com/rauenzi/BDPluginLibrary/master/release/0PluginLibrary.plugin.js"); + await new Promise(r => fs.writeFile(require("path").join(BdApi.Plugins.folder, "0PluginLibrary.plugin.js"), body, () => { + BdApi.alert("Successfully installed CrossPlatformPlaying! Press Ctrl+R to reload Discord, then go to Settings > Plugins to enable and configure the plugin."); + r(); + })); + }); + } + }); + } + start() {} + stop() {} + } : (([Plugin, Api]) => { + const plugin = (Plugin, Library) => { + return class CrossPlatformPlaying extends Plugin { + constructor() { + super(); + } + + onStart() { + // Required function. Called when the plugin is activated (including after reloads) + + console.log("[CrossPlatformPlaying] Starting..."); + const loadStart = Date.now(); + + // for debugging + global.CPP = { + plugin: this, platforms, config, preload, + fetch, setTimeout, setInterval, updateUser, removeFromList, + Platform, SimpleSocket, SettingsBuilder, Priorities, + timeouts, intervals + }; + + // check if config json is valid + try { + BdApi.loadData(pluginName, ""); + } catch(e) { + return console.error("[CrossPlatformPlaying] Could not load config JSON! Is it corrupt?"); + } + + discord_id = BdApi.findModuleByProps("getUser", "getCurrentUser").getCurrentUser().id; + + this.loadPatches(); + this.patchGetActivity(); + this.patchRenderHeader(); + this.setUpdateUser(); + + // initialise platforms + this.instances = []; + for(const platform of platforms) { + this.instances.push(new platform()); + } + + console.log(`[CrossPlatformPlaying] Started in ${Date.now() - loadStart}ms`); + } + + onStop() { + // Required function. Called when the plugin is deactivated + BdApi.Patcher.unpatchAll(pluginName); + + for(const platform of this.instances) platform.destroy(true); + this.instances.length = 0; // clear array for next time plugin is launched + + for(const timeout of timeouts) clearTimeout(timeout); + for(const interval of intervals) clearInterval(interval); + timeouts.length = 0; intervals.length = 0; + } + + getSettingsPanel() { + if(!this.buttonClassNames) this.buttonClassNames = BdApi.findModuleByProps("button", "lookFilled", "grow"); + + const body = document.createElement("div"); + + // to check if settings panel is still open + const bodyId = crypto.randomBytes(16).toString("base64"); + body.id = bodyId; + + const theInterval = setInterval(() => { + if(document.getElementById(bodyId)) { + for(let i = 0; i < buttons.length; i++) { + const platform = this.instances[i]; + const button = buttons[i]; + + if(platform.enabled) { + button.classList.remove(this.buttonClassNames.colorTransparent); + button.classList.add(this.buttonClassNames.colorBrand); + } else { + button.classList.remove(this.buttonClassNames.colorBrand); + button.classList.add(this.buttonClassNames.colorTransparent); + } + + } + } else { // settings panel closed + clearInterval(theInterval); + } + }, 100); + + const buttons = []; + const panels = []; + let activePanel; + + // insert formatted list of all discord users + const discordUserList = { + idToName: {}, + nameToId: {} + } + ZeresPluginLibrary.DiscordModules.UserStore.forEach(user => { + const name = user.username + '#' + user.discriminator; + discordUserList.idToName[user.id] = name; + discordUserList.nameToId[name] = user.id; + }); + const discordUsersDatalist = SettingsBuilder.createDatalist("discordUserList", Object.keys(discordUserList.nameToId)); + body.append(discordUsersDatalist); + + // create a panel and button for each platform + for(const platform of this.instances) { + let platformIndex = buttons.length; + + // the different panels for different platforms + const panel = document.createElement("div"); + panel.id = "panel-" + platform.platformId; + panel.loaded = false; + panel.style.display = "none"; // hide the panel initially + panels.push(panel); + + // the buttons clicked to show the different platforms + const button = document.createElement("button"); + button.classList.add(this.buttonClassNames.button, this.buttonClassNames.lookFilled, this.buttonClassNames.colorBrand, this.buttonClassNames.sizeMedium, this.buttonClassNames.grow); + button.style.margin = "5px"; + button.style.display = "inline"; // to make the buttons appear side by side + button.innerText = platform.constructor.name; + button.onclick = () => { + if(activePanel) activePanel.style.display = "none"; + if(this.activePanelIndex !== undefined) { + buttons[this.activePanelIndex].disabled = false; + buttons[this.activePanelIndex].classList.remove("bd-button-disabled"); + } + + activePanel = panel; + this.activePanelIndex = platformIndex; + + if(!panel.loaded) { + panel.append(platform.getSettings(discordUserList, discordUsersDatalist)); + panel.loaded = true; + } + + panel.style.display = "block"; + button.disabled = true; + button.classList.add("bd-button-disabled"); + } + + buttons.push(button); + } + + // start inserting the elements + const buttonsDiv = document.createElement("div"); + buttonsDiv.id = "platform-buttons"; + buttonsDiv.style.margin = "10px"; + body.append(buttonsDiv); + + for(const button of buttons) { + buttonsDiv.append(button); + } + + const panelsDiv = document.createElement("div"); + panelsDiv.id = "platform-panels"; + panelsDiv.style.padding = "10px"; + body.append(panelsDiv); + + for(const panel of panels) { + panelsDiv.append(panel); + } + + buttons[this.activePanelIndex || 0].click(); + + return body; + } + + loadPatches() { + // discord webpack module helper functions + const moduleList = Object.values(ZeresPluginLibrary.WebpackModules.getAllModules()); + + const activityRenderModule = moduleList.find(m => m.exports?.Z?.prototype?.renderImage); + + // module that returns "Playing a game" header + // fetching it is slow though, so I opted to compare the final header name to the one in the strings module + // const gameHeaderModule = moduleList.find(m => m.exports.Z?.toString().includes("USER_ACTIVITY_HEADER_PLAYING")).exports.Z.name + + const stringsModule = moduleList.find(m => m.exports.Z?.Messages?.USER_ACTIVITY_HEADER_PLAYING); + + const ActivityStore = ZeresPluginLibrary.DiscordModules.UserStatusStore; + + this.patchGetActivity = () => { + BdApi.Patcher.after(pluginName, ActivityStore, "getActivities", (_this, args, ret) => { + const start = Date.now(); + + try { + const id = args[0]; + + let newActivities = []; + + for(const platform of this.instances) { + const presences = platform.getPresence(id); + if(presences && presences.length) newActivities.push(...presences); + } + + if(newActivities.length === 0) return ret; + + const activityPriority = (act) => { + if(act.priority) return act.priority; + if(act.type === 2) return Priorities.SECONDARY; // Spotify + if(act.application_id === "307998818547531777") return Priorities.SECONDARY; // Medal.tv + if(act.details || act.state) return Priorities.DISCORD_RICH_PRESENCE; + return Priorities.DISCORD_NORMAL; + } + const sortFunction = (a, b) => { + return activityPriority(b) - activityPriority(a); + } + return newActivities.concat(ret).sort(sortFunction); + } finally { + const time = Date.now() - start; + if(time > 5) console.warn(`[${pluginName}] getActivities took ${time}ms`); + } + }); + + global.CPP.getActivities = ZeresPluginLibrary.DiscordModules.UserStatusStore.getActivities; + } + + this.setUpdateUser = () => { + const dispatchFunction = ZeresPluginLibrary.DiscordModules.UserStatusStore._dispatcher.dispatch.bind(ZLibrary.DiscordModules.UserStatusStore._dispatcher); + + updateUser = (id) => { + const user = ZeresPluginLibrary.DiscordModules.UserStore.getUser(id); + /*if(user) console.log(`Updating user ${user.username}#${user.discriminator}`); + else console.log(`Updating user with id ${id}...`);*/ + + if(user) dispatchFunction({ + type: "GUILD_MEMBER_UPDATE", + guildId: null, // update in all guilds + user: user + }); + } + global.CPP.updateUser = updateUser; + } + + this.patchRenderHeader = () => { + BdApi.Patcher.after(pluginName, activityRenderModule.exports.Z.prototype, "renderHeader", (_this, args, ret) => { + if(!ret) return ret; + + const activity = _this.activity; + if(!activity?.username) return ret; + + const prop = ret.props.children[1].props.children.props; + const playingAGame = stringsModule.exports.Z.Messages.USER_ACTIVITY_HEADER_PLAYING; + + if(prop.children === playingAGame) prop.children = `Playing as ${activity.username}`; + else prop.children += ` as ${activity.username}`; + + return ret; + }); + } + + /*this.reloadActivityRenderModule = () => { + // the activityRenderModule.renderImage function stores a reference to the unpatched getAssetImage function + // we need to reload the webpack to update the reference + const reloadedModule = {exports: {}}; + ZeresPluginLibrary.WebpackModules.require.m[activityRenderModule.id](reloadedModule, reloadedModule.exports, ZeresPluginLibrary.WebpackModules.require); + + BdApi.Patcher.instead(pluginName, activityRenderModule.exports.default.prototype, "renderImage", (_this, args) => reloadedModule.exports.default.prototype.renderImage.call(_this, ...args)); + BdApi.Patcher.instead(pluginName, activityRenderModule.exports.default.prototype, "renderHeader", (_this, args) => reloadedModule.exports.default.prototype.renderHeader.call(_this, ...args)); + }*/ + } + }; + }; + return plugin(Plugin, Api); + })(global.ZeresPluginLibrary.buildPlugin(config)); + })(); + /*@end@*/ + \ No newline at end of file diff --git a/add_this_to_index.js b/add_this_to_index.js new file mode 100644 index 0000000..292655e --- /dev/null +++ b/add_this_to_index.js @@ -0,0 +1,23 @@ +// CPP-PRELOAD START v0.1 +// credit for this code goes to retr0! +const electron = require("electron"), electronPath = require.resolve("electron"), path = require('path'); + +class BrowserWindow extends electron.BrowserWindow { + constructor(options) { + let _preload = options.webPreferences.preload;; + options.webPreferences.preload = path.join(__dirname, "preload.js"); + const window = new electron.BrowserWindow(options); + window.webContents._original_preload = _preload; + return window; + } +} +const newElectron = Object.assign({}, electron, { BrowserWindow }); + +delete require.cache[electronPath].exports; +require.cache[electronPath].exports = newElectron; + +electron.ipcMain.on("_original_preload", function waitForPreload(e) { + if (typeof e.sender._original_preload !== "undefined") e.returnValue = e.sender._original_preload; + else setTimeout(waitForPreload, 50, e); +}); +// CPP-PRELOAD END \ No newline at end of file From e12279ca520c921b3ff09e45f1a47f03eef5a6a3 Mon Sep 17 00:00:00 2001 From: Giorgio <20621396+giorgi-o@users.noreply.github.com> Date: Fri, 4 Nov 2022 00:45:44 +0000 Subject: [PATCH 2/3] Fix preload filename Thx Shades on Discord --- add_this_to_index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/add_this_to_index.js b/add_this_to_index.js index 292655e..68e7893 100644 --- a/add_this_to_index.js +++ b/add_this_to_index.js @@ -5,7 +5,7 @@ const electron = require("electron"), electronPath = require.resolve("electron") class BrowserWindow extends electron.BrowserWindow { constructor(options) { let _preload = options.webPreferences.preload;; - options.webPreferences.preload = path.join(__dirname, "preload.js"); + options.webPreferences.preload = path.join(__dirname, "CPP_preload.js"); const window = new electron.BrowserWindow(options); window.webContents._original_preload = _preload; return window; @@ -20,4 +20,4 @@ electron.ipcMain.on("_original_preload", function waitForPreload(e) { if (typeof e.sender._original_preload !== "undefined") e.returnValue = e.sender._original_preload; else setTimeout(waitForPreload, 50, e); }); -// CPP-PRELOAD END \ No newline at end of file +// CPP-PRELOAD END From 755ead782e95c6826c7db5dcd2387da1af488662 Mon Sep 17 00:00:00 2001 From: Giorgio <20621396+giorgi-o@users.noreply.github.com> Date: Wed, 30 Nov 2022 17:42:08 +0000 Subject: [PATCH 3/3] Update CrossPlatformPlaying.plugin.js --- CrossPlatformPlaying.plugin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CrossPlatformPlaying.plugin.js b/CrossPlatformPlaying.plugin.js index 1421704..eb3f1ac 100644 --- a/CrossPlatformPlaying.plugin.js +++ b/CrossPlatformPlaying.plugin.js @@ -4427,7 +4427,7 @@ // fetching it is slow though, so I opted to compare the final header name to the one in the strings module // const gameHeaderModule = moduleList.find(m => m.exports.Z?.toString().includes("USER_ACTIVITY_HEADER_PLAYING")).exports.Z.name - const stringsModule = moduleList.find(m => m.exports.Z?.Messages?.USER_ACTIVITY_HEADER_PLAYING); + const stringsModule = moduleList.find(m => m.exports?.Z?.Messages?.USER_ACTIVITY_HEADER_PLAYING); const ActivityStore = ZeresPluginLibrary.DiscordModules.UserStatusStore; @@ -4517,4 +4517,4 @@ })(global.ZeresPluginLibrary.buildPlugin(config)); })(); /*@end@*/ - \ No newline at end of file +