From 696fd077fad7235134485d1ea43ee2d69d2be39c Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 27 Mar 2019 16:06:25 -0700 Subject: [PATCH] Create a Discord bot --- experiments/discord-bot/.gitignore | 2 + experiments/discord-bot/commands/approve.js | 37 +++++ experiments/discord-bot/commands/archive.js | 60 ++++++++ experiments/discord-bot/commands/random.js | 24 +++ experiments/discord-bot/commands/reject.js | 37 +++++ experiments/discord-bot/commands/review.js | 35 +++++ experiments/discord-bot/commands/skin.js | 19 +++ experiments/discord-bot/commands/stats.js | 25 ++++ experiments/discord-bot/index.js | 68 +++++++++ experiments/discord-bot/info.js | 27 ++++ experiments/discord-bot/package.json | 17 +++ experiments/discord-bot/s3.js | 115 +++++++++++++++ experiments/discord-bot/utils.js | 94 ++++++++++++ experiments/discord-bot/yarn.lock | 156 ++++++++++++++++++++ 14 files changed, 716 insertions(+) create mode 100644 experiments/discord-bot/.gitignore create mode 100644 experiments/discord-bot/commands/approve.js create mode 100644 experiments/discord-bot/commands/archive.js create mode 100644 experiments/discord-bot/commands/random.js create mode 100644 experiments/discord-bot/commands/reject.js create mode 100644 experiments/discord-bot/commands/review.js create mode 100644 experiments/discord-bot/commands/skin.js create mode 100644 experiments/discord-bot/commands/stats.js create mode 100644 experiments/discord-bot/index.js create mode 100644 experiments/discord-bot/info.js create mode 100644 experiments/discord-bot/package.json create mode 100644 experiments/discord-bot/s3.js create mode 100644 experiments/discord-bot/utils.js create mode 100644 experiments/discord-bot/yarn.lock diff --git a/experiments/discord-bot/.gitignore b/experiments/discord-bot/.gitignore new file mode 100644 index 00000000..000abf2e --- /dev/null +++ b/experiments/discord-bot/.gitignore @@ -0,0 +1,2 @@ +config.js +node_modules/ \ No newline at end of file diff --git a/experiments/discord-bot/commands/approve.js b/experiments/discord-bot/commands/approve.js new file mode 100644 index 00000000..3654fbba --- /dev/null +++ b/experiments/discord-bot/commands/approve.js @@ -0,0 +1,37 @@ +const { approve, getStatus } = require("../s3"); +const Utils = require("../utils"); + +const { getFilename } = require("../info"); +const TWEET_BOT_CHANNEL_ID = "445577489834704906"; + +async function handler(message, args) { + const [md5] = args; + const filename = getFilename(md5); + if (filename == null) { + await message.channel.send(`Could not find a skin matching ${md5}`); + return; + } + const status = await getStatus(md5); + if (status !== "UNREVIEWED") { + await message.channel.send(`This skin has already been reviewed.`); + return; + } + await approve(md5); + const tweetBotChannel = message.client.channels.get(TWEET_BOT_CHANNEL_ID); + await Utils.postSkin({ + filename, + md5, + title: `Approved: ${filename}`, + dest: tweetBotChannel + }); + await tweetBotChannel.send( + `${filename} was approved by ${message.author.username}` + ); +} + +module.exports = { + command: "approve", + handler, + usage: "", + description: "Approve a given skin to be Tweeted by the Twitter bot" +}; diff --git a/experiments/discord-bot/commands/archive.js b/experiments/discord-bot/commands/archive.js new file mode 100644 index 00000000..4438ff59 --- /dev/null +++ b/experiments/discord-bot/commands/archive.js @@ -0,0 +1,60 @@ +const fs = require("fs"); +const path = require("path"); +const fetch = require("node-fetch"); +const md5Buffer = require("md5"); + +const { getInfo } = require("../info"); +const Skin = require("./skin"); + +async function handler(message, args) { + const { attachments } = message; + if (attachments.length < 1) { + await message.channel.send("Could not archive. No attachment found."); + return; + } + const files = await Promise.all( + attachments.map(async attachment => { + const { filename, url } = attachment; + const response = await fetch(url); + console.log("got response"); + const buffer = await response.buffer(); + console.log("got buffer"); + const md5 = md5Buffer(buffer); + console.log("got md5", md5); + return { filename, buffer, md5 }; + }) + ); + + for (const file of files) { + const info = await getInfo(file.md5); + if (info == null) { + fs.writeFileSync( + path.join( + "/Volumes/Mobile Backup/skins/skins/discord-bot", + // TODO: Use a sub directory using md5 to avoid collision + // file.md5, + file.filename + ), + file.buffer + ); + await message.channel.send( + `Thanks! ${ + file.filename + } is a brand new skin. 👏 It has been queued for archiving.` + ); + } else { + await message.channel.send( + `We already have ${file.filename} in our collection` + ); + await Skin.handler(message, [file.md5]); + } + } +} + +module.exports = { + command: "archive", + usage: "", + description: + "Queues the accompanying uploaded skin for inclusion in the Archive", + handler +}; diff --git a/experiments/discord-bot/commands/random.js b/experiments/discord-bot/commands/random.js new file mode 100644 index 00000000..a780e5ba --- /dev/null +++ b/experiments/discord-bot/commands/random.js @@ -0,0 +1,24 @@ +const Utils = require("../utils"); + +const { getCache, getFilename } = require("../info"); + +async function handler(message) { + const cache = getCache(); + const skins = Object.values(cache).filter(skin => skin.type === "CLASSIC"); + const skin = skins[Math.floor(Math.random() * skins.length)]; + const { md5 } = skin; + const filename = getFilename(md5); + await Utils.postSkin({ + filename, + md5, + title: `${filename}`, + dest: message.channel + }); +} + +module.exports = { + usage: "", + description: "Show information about a random skin", + command: "random", + handler +}; diff --git a/experiments/discord-bot/commands/reject.js b/experiments/discord-bot/commands/reject.js new file mode 100644 index 00000000..a953cb3d --- /dev/null +++ b/experiments/discord-bot/commands/reject.js @@ -0,0 +1,37 @@ +const { reject, getStatus } = require("../s3"); +const Utils = require("../utils"); + +const { getFilename } = require("../info"); +const TWEET_BOT_CHANNEL_ID = "445577489834704906"; + +async function handler(message, args) { + const [md5] = args; + const filename = getFilename(md5); + if (filename == null) { + await message.channel.send(`Could not find a skin matching ${md5}`); + return; + } + const status = await getStatus(md5); + if (status !== "UNREVIEWED") { + await message.channel.send(`This skin has already been reviewed.`); + return; + } + await reject(md5); + const tweetBotChannel = message.client.channels.get(TWEET_BOT_CHANNEL_ID); + await Utils.postSkin({ + filename, + md5, + title: `Rejected: ${filename}`, + dest: tweetBotChannel + }); + await tweetBotChannel.send( + `${filename} was rejected by ${message.author.username}` + ); +} + +module.exports = { + command: "reject", + handler, + usage: "", + description: "Reject a given skin from being Tweeted by the Twitter bot" +}; diff --git a/experiments/discord-bot/commands/review.js b/experiments/discord-bot/commands/review.js new file mode 100644 index 00000000..b8adfbd1 --- /dev/null +++ b/experiments/discord-bot/commands/review.js @@ -0,0 +1,35 @@ +const { getSkinToReview } = require("../s3"); +const Utils = require("../utils"); + +async function reviewSkin(message) { + const { filename, md5 } = await getSkinToReview(); + await Utils.postSkin({ + filename, + md5, + title: `Review: ${filename}`, + dest: message.client.channels + }); +} + +async function handler(message, args) { + const [arg1] = args; + const count = arg1 ? Math.min(arg1, 10) : 1; + if (count > 1) { + message.channel.send(`Going to show ${count} skins to review`); + } + let i = Number(count); + while (i--) { + await reviewSkin(message); + } + if (count > 1) { + message.channel.send(`Done reviewing ${count} skins. Thanks!`); + } +} + +module.exports = { + command: "review", + handler, + usage: "[]", + description: + "Post a of skins to be reviewed for inclusion in the Twitter bot. Defaults to 1" +}; diff --git a/experiments/discord-bot/commands/skin.js b/experiments/discord-bot/commands/skin.js new file mode 100644 index 00000000..ae7e3f67 --- /dev/null +++ b/experiments/discord-bot/commands/skin.js @@ -0,0 +1,19 @@ +const { getFilename } = require("../info"); +const Utils = require("../utils"); +async function handler(message, args) { + const [md5] = args; + const filename = getFilename(md5); + await Utils.postSkin({ + filename, + md5, + title: `${filename}`, + dest: message.channel + }); +} + +module.exports = { + usage: "", + description: "Show information about a skin", + command: "skin", + handler +}; diff --git a/experiments/discord-bot/commands/stats.js b/experiments/discord-bot/commands/stats.js new file mode 100644 index 00000000..13f6870b --- /dev/null +++ b/experiments/discord-bot/commands/stats.js @@ -0,0 +1,25 @@ +const { getCache } = require("../info"); +const { getStats } = require("../s3"); + +async function handler(message, args) { + const info = getCache(); + let classic = 0; + const { tweeted, approved, rejected } = await getStats(); + Object.values(info).forEach(skin => { + if (skin.type === "CLASSIC") { + classic++; + } + }); + await message.channel.send(`Unique Skins: ${classic.toLocaleString()} +Tweeted: ${tweeted.toLocaleString()} +Rejected: ${rejected.toLocaleString()} +Approved: ${approved.toLocaleString()} + `); +} + +module.exports = { + command: "stats", + handler, + usage: "", + description: "Give some statistics about the skin archive" +}; diff --git a/experiments/discord-bot/index.js b/experiments/discord-bot/index.js new file mode 100644 index 00000000..7d71b81d --- /dev/null +++ b/experiments/discord-bot/index.js @@ -0,0 +1,68 @@ +const fs = require("fs"); +const Discord = require("discord.js"); +const config = require("./config"); + +const client = new Discord.Client(); + +function handleStats() {} + +const handlers = { + stats: handleStats, + help: handleHelp +}; + +const commands = fs + .readdirSync("./commands") + .filter(file => file.endsWith(".js")) + .map(file => { + return require(`./commands/${file}`); + }); + +for (const command of commands) { + handlers[command.command] = command.handler; +} + +async function handleHelp(message, args) { + const commandHelp = commands + .map( + command => + `\`!${command.command} ${command.usage || ""}\` -- ${ + command.description + }` + ) + .join("\n"); + + const help = ` +I am a helpful bot that can do things with Winamp skins. Here are the commands I know: + +${commandHelp} +\`!help\` Show this message + +You can issue these commands in a DM to me or in any channel. For tasks relating to approving skins, I will respond in the #tweet-bot channel. +`.trim(); + message.channel.send(help); +} + +client.on("message", async message => { + if (message.author.bot) { + return; + } + const [rawCommand, ...args] = message.content.split(" "); + if (!rawCommand.startsWith("!")) { + return; + } + const command = rawCommand.slice(1); + console.log("New command: ", command); + console.log("User: ", message.author.username); + console.log("Arguments: ", args); + const handler = handlers[command]; + if (handler != null) { + handler(message, args); + } +}); + +client.on("error", error => { + console.error("The WebSocket encountered an error:", error); +}); + +client.login(config.discordToken); diff --git a/experiments/discord-bot/info.js b/experiments/discord-bot/info.js new file mode 100644 index 00000000..4b333bb7 --- /dev/null +++ b/experiments/discord-bot/info.js @@ -0,0 +1,27 @@ +const path = require("path"); +const fs = require("fs"); + +let cache = null; + +function getCache() { + if (cache == null) { + cache = JSON.parse( + fs.readFileSync( + path.join("/Volumes/Mobile Backup/skins/cache", "info.json"), + "utf8" + ) + ); + } + return cache; +} + +function getInfo(md5) { + return getCache()[md5]; +} + +function getFilename(md5) { + const info = getInfo(md5); + return info.filePaths.map(filepath => path.basename(filepath))[0]; +} + +module.exports = { getInfo, getCache, getFilename }; diff --git a/experiments/discord-bot/package.json b/experiments/discord-bot/package.json new file mode 100644 index 00000000..29c22d21 --- /dev/null +++ b/experiments/discord-bot/package.json @@ -0,0 +1,17 @@ +{ + "name": "discord-bot", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "aws-sdk": "^2.429.0", + "discord.js": "^11.4.2", + "md5": "^2.2.1", + "node-fetch": "^2.3.0", + "rgb-hex": "^2.1.0" + }, + "scripts": { + "start": "node index.js" + }, + "prettier": {} +} diff --git a/experiments/discord-bot/s3.js b/experiments/discord-bot/s3.js new file mode 100644 index 00000000..a5402a20 --- /dev/null +++ b/experiments/discord-bot/s3.js @@ -0,0 +1,115 @@ +const AWS = require("aws-sdk"); +AWS.config.update({ region: "us-west-2" }); + +const s3 = new AWS.S3(); + +function getFile(key) { + return new Promise((resolve, reject) => { + const bucketName = "winamp2-js-skins"; + s3.getObject({ Bucket: bucketName, Key: key }, (err, data) => { + if (err) { + reject(err); + return; + } + const body = Buffer.from(data.Body).toString("utf8"); + resolve(body); + }); + }); +} + +function putFile(key, body) { + return new Promise((resolve, reject) => { + const bucketName = "winamp2-js-skins"; + s3.putObject({ Bucket: bucketName, Key: key, Body: body }, (err, data) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + +function getLines(body) { + return body.split("\n").map(line => line.trim()); +} + +async function getStatus(md5) { + const [approved, rejected, tweeted] = await Promise.all([ + getFile("approved.txt"), + getFile("rejected.txt"), + getFile("tweeted.txt") + ]); + const approvedSet = new Set(getLines(approved)); + const rejectedSet = new Set(getLines(rejected)); + const tweetedSet = new Set(getLines(tweeted)); + if (tweetedSet.has(md5)) { + return "TWEETED"; + } + if (rejectedSet.has(md5)) { + return "REJECTED"; + } + if (approvedSet.has(md5)) { + return "APPROVED"; + } + return "UNREVIEWED"; +} + +async function getStats() { + const [approved, rejected, tweeted] = await Promise.all([ + getFile("approved.txt"), + getFile("rejected.txt"), + getFile("tweeted.txt") + ]); + return { + approved: new Set(approved).size - new Set(tweeted).size, + rejected: new Set(rejected).size, + tweeted: new Set(tweeted).size + }; +} + +async function getSkinToReview() { + console.log("Reading from s3..."); + const [filenames, approved, rejected, tweeted] = await Promise.all([ + getFile("filenames.txt"), + getFile("approved.txt"), + getFile("rejected.txt"), + getFile("tweeted.txt") + ]); + console.log("Got all files"); + + const approvedSet = new Set(getLines(approved)); + const rejectedSet = new Set(getLines(rejected)); + const tweetedSet = new Set(getLines(tweeted)); + + const filenameLines = getLines(filenames); + const skins = filenameLines.map(line => { + const [md5, ...filename] = line.split(" "); + return { md5, filename: filename.join(" ") }; + }); + const toReview = skins.filter(({ md5 }) => { + return !( + approvedSet.has(md5) || + rejectedSet.has(md5) || + tweetedSet.has(md5) + ); + }); + console.log(toReview.length, "skins to review"); + return toReview[0]; +} + +async function appendLine(key, line) { + const currentContent = await getFile(key); + const newContent = `${currentContent}${line}\n`; + return putFile(key, newContent); +} + +async function approve(md5) { + return appendLine("approved.txt", md5); +} + +async function reject(md5) { + return appendLine("rejected.txt", md5); +} + +module.exports = { getSkinToReview, approve, reject, getStatus, getStats }; diff --git a/experiments/discord-bot/utils.js b/experiments/discord-bot/utils.js new file mode 100644 index 00000000..fb3436ed --- /dev/null +++ b/experiments/discord-bot/utils.js @@ -0,0 +1,94 @@ +const Discord = require("discord.js"); +const rgbHex = require("rgb-hex"); +const { getInfo } = require("./info"); +const { approve, reject, getStatus } = require("./s3"); + +const filter = reaction => { + return ["👍", "👎"].some(name => reaction.emoji.name === name); +}; + +async function postSkin({ filename, md5, title, dest }) { + const screenshotUrl = `https://s3.amazonaws.com/webamp-uploaded-skins/screenshots/${md5}.png`; + const skinUrl = `https://s3.amazonaws.com/webamp-uploaded-skins/skins/${md5}.wsz`; + const webampUrl = `https://webamp.org?skinUrl=${skinUrl}`; + + const embed = new Discord.RichEmbed() + .setTitle(title) + .addField("Try Online", `[webamp.org](${webampUrl})`, true) + .addField("Download", `[${filename}](${skinUrl})`, true) + .addField("Md5", md5, true) + .setImage(screenshotUrl); + + const info = getInfo(md5); + if (info == null) { + embed.addField("Warning", "Cached metadata not found", true); + } else { + if (info.averageColor) { + const color = rgbHex(info.averageColor); + if (String(color).length === 6) { + embed.setColor(`#${color}`); + } else { + console.log("Did not get a safe color from ", info.averageColor); + console.log("Got ", color); + } + } + if (info.emails != null && info.emails.length) { + const uniqueEmails = Array.from(new Set(info.emails)); + // TODO: Fix email parsing + embed.addField("Emails", uniqueEmails.join(", "), true); + } + if (info.tweetUrl != null) { + let likes = ""; + if (info.twitterLikes != null) { + likes = `(${Number(info.twitterLikes).toLocaleString()} likes) `; + } + embed.addField( + "Tweet Status", + `[Tweeted](${info.tweetUrl}) ${likes}🐦`, + true + ); + } else { + const status = await getStatus(md5); + if (status === "UNREVIEWED") { + embed.setFooter("React with 👍 or 👎 to approve or deny"); + } + embed.addField("Tweet Status", getPrettyTwitterStatus(status), true); + } + } + + const msg = await dest.send(embed); + + await msg.awaitReactions(filter, { max: 1 }).then(async collected => { + const vote = collected.first(); + const user = vote.users.first(); + switch (vote.emoji.name) { + case "👍": + await approve(md5); + console.log(`${user.username} approved ${md5}`); + await msg.channel.send(`${filename} was approved by ${user.username}`); + msg.react("✅"); + break; + case "👎": + await reject(md5); + console.log(`${user.username} rejected ${md5}`); + await msg.channel.send(`${filename} was rejected by ${user.username}`); + msg.react("❌"); + break; + } + }); +} + +function getPrettyTwitterStatus(status) { + switch (status) { + case "APPROVED": + return "Approved ✅"; + case "REJECTED": + return "Rejected ❌"; + case "UNREVIEWED": + return "Unreviewed ❔"; + case "TWEETED": + return "Tweeted 🐦"; + } +} + +module.exports = { postSkin }; diff --git a/experiments/discord-bot/yarn.lock b/experiments/discord-bot/yarn.lock new file mode 100644 index 00000000..aba0fd00 --- /dev/null +++ b/experiments/discord-bot/yarn.lock @@ -0,0 +1,156 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +async-limiter@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" + +aws-sdk@^2.429.0: + version "2.429.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.429.0.tgz#27c32734f6473f69a39c29a694d54d15b5beb68a" + dependencies: + buffer "4.9.1" + events "1.1.1" + ieee754 "1.1.8" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + +base64-js@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" + +buffer@4.9.1: + version "4.9.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +charenc@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + +crypt@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + +discord.js@^11.4.2: + version "11.4.2" + resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-11.4.2.tgz#54586981926521572051f2a30b984aad2b49786e" + dependencies: + long "^4.0.0" + prism-media "^0.0.3" + snekfetch "^3.6.4" + tweetnacl "^1.0.0" + ws "^4.0.0" + +events@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + +ieee754@1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" + +ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + +is-buffer@~1.1.1: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + +isarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + +md5@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" + dependencies: + charenc "~0.0.1" + crypt "~0.0.1" + is-buffer "~1.1.1" + +node-fetch@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" + +prism-media@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-0.0.3.tgz#8842d4fae804f099d3b48a9a38e3c2bab6f4855b" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + +rgb-hex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/rgb-hex/-/rgb-hex-2.1.0.tgz#c773c5fe2268a25578d92539a82a7a5ce53beda6" + +safe-buffer@~5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + +sax@>=0.6.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + +snekfetch@^3.6.4: + version "3.6.4" + resolved "https://registry.yarnpkg.com/snekfetch/-/snekfetch-3.6.4.tgz#d13e80a616d892f3d38daae4289f4d258a645120" + +tweetnacl@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.1.tgz#2594d42da73cd036bd0d2a54683dd35a6b55ca17" + +url@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + +ws@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-4.1.0.tgz#a979b5d7d4da68bf54efe0408967c324869a7289" + dependencies: + async-limiter "~1.0.0" + safe-buffer "~5.1.0" + +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + +xmlbuilder@~9.0.1: + version "9.0.7" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"