add custom discord client and add polling endpoint

This commit is contained in:
hippoz 2022-02-02 11:46:42 +02:00
parent 3dd2e6ada1
commit 0697dd0a82
Signed by: hippoz
GPG key ID: 7C52899193467641
10 changed files with 435 additions and 114 deletions

238
DiscordClient.js Normal file
View file

@ -0,0 +1,238 @@
import EventEmitter from "events";
import zlib from "zlib";
import { WebSocket } from "ws";
import fetch from "node-fetch";
const opcodes = {
EVENT: 0,
CLIENT_HEARTBEAT: 1,
IDENTIFY: 2,
HELLO: 10,
HEARTBEAT_ACK: 11,
};
class DiscordClient extends EventEmitter {
constructor(token, { intents, baseDomain="discord.com", gatewayUrl="wss://gateway.discord.gg/?v=9&encoding=json&compress=zlib-stream", apiBase="https://discord.com/api/v9" } = {}) {
super();
this.token = token;
this.gatewayUrl = gatewayUrl;
this.apiBase = apiBase;
this.inflate = zlib.createInflate({
chunkSize: 128 * 1024
});
this.ws = null;
this.intents = intents;
this.user = null;
this.guilds = [];
this.sessionId = null;
this.seq = null;
this.gotServerHeartbeatACK = true;
}
_setHeartbeat(interval) {
this._heartbeatIntervalTime = interval;
if (interval < 0 && this._heartbeatInterval) {
clearInterval(this._heartbeatInterval);
return;
}
this._heartbeatInterval = setInterval(() => {
if (!this.gotServerHeartbeatACK) {
this.emit("error", "NO_HEARTBEAT_ACK");
return;
}
this.gotServerHeartbeatACK = false;
this.ws.send(JSON.stringify({
op: opcodes.CLIENT_HEARTBEAT,
d: this.seq
}));
}, this._heartbeatIntervalTime);
}
_handleGatewayMessage(ws, message) {
try {
message = JSON.parse(message);
} catch(e) {
console.error("error: DiscordClient: on `message`: failed to parse incoming message as JSON", e);
return;
}
if (message.s) {
this.seq = message.s;
}
const payload = message.d;
switch (message.op) {
case opcodes.HELLO: {
this._setHeartbeat(payload.heartbeat_interval);
ws.send(JSON.stringify({
op: opcodes.IDENTIFY,
d: {
token: this.token,
intents: this.intents,
properties: {
"$os": "linux",
"$browser": "generic",
"$device": "generic"
},
presence: {
since: Date.now(),
activities: [
{
type: 2, // LISTENING
name: "the voices"
}
],
status: "online",
afk: false
}
}
}));
break;
}
case opcodes.EVENT: {
switch (message.t) {
case "READY": {
console.log("DiscordClient: ready");
this.user = payload.user;
this.sessionId = payload.session_id;
this.guilds = payload.guilds;
break;
}
case "GUILD_CREATE": {
const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id);
if (targetGuildIndex < 0) {
this.guilds.push(payload);
break;
}
// The guild already exists in our array. This means that
// this GUILD_CREATE event is completing our `Unavailable Guild`
// objects that we got from the initial READY.
this.guilds[targetGuildIndex] = payload;
break;
}
case "GUILD_UPDATE": {
const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id);
if (targetGuildIndex < 0) {
// tried to update a guild that doesn't exist???
this.emit("warn", "got GUILD_UPDATE for a guild that doesn't exist");
break;
}
this.guilds[targetGuildIndex] = payload;
break;
}
case "GUILD_DELETE": {
const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id);
if (targetGuildIndex < 0) {
// tried to delete a guild that doesn't exist???
this.emit("warn", "got GUILD_DELETE for a guild that doesn't exist");
break;
}
this.guilds.splice(targetGuildIndex, 1);
break;
}
case "CHANNEL_CREATE": {
const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id);
if (parentGuildIndex < 0) {
this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist");
break;
}
this.guilds[parentGuildIndex].channels.push(payload);
break;
}
case "CHANNEL_UPDATE": {
const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id);
if (parentGuildIndex < 0) {
this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist");
break;
}
const relevantChannelIndex = this.guilds[parentGuildIndex].channels.findIndex(e => e.id === payload.id);
this.guilds[parentGuildIndex].channels[relevantChannelIndex] = payload;
break;
}
case "CHANNEL_DELETE": {
const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id);
if (parentGuildIndex < 0) {
this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist");
break;
}
const relevantChannelIndex = this.guilds[parentGuildIndex].channels.findIndex(e => e.id === payload.id);
this.guilds[parentGuildIndex].channels.splice(relevantChannelIndex, 1);
break;
}
}
this.emit(message.t, payload);
break;
}
case opcodes.HEARTBEAT_ACK: {
this.gotServerHeartbeatACK = true;
break;
}
default: {
console.warn(`warn: DiscordClient: got unhandled opcode "${message.op}"`);
break;
}
}
}
connect() {
const ws = new WebSocket(this.gatewayUrl);
this.ws = ws;
// we decompressed the data, send it to the handler now
this.inflate.on("data", (message) => this._handleGatewayMessage(ws, message));
ws.on("message", (data, isBinary) => {
// pass the data to the decompressor
this.inflate.write(data);
});
ws.on("close", (code, reason) => {
reason = reason.toString();
console.error(`DiscordClient: on \`close\`: disconnected from gateway: code \`${code}\`, reason \`${reason}\``);
})
}
async api([method, path], body=undefined, throwOnError=true) {
const options = {
method,
headers: {
"authorization": `Bot ${this.token}`
}
};
if (method !== "GET" && method !== "HEAD" && typeof body === "object") {
options.headers["content-type"] = "application/json";
options.body = JSON.stringify(body);
}
const response = await fetch(`${this.apiBase}${path}`, options);
const json = await response.json();
if (!response.ok && throwOnError) {
throw new Error(`API request returned non-success status ${response.status}, with JSON body ${JSON.stringify(json)}`);
}
return json;
}
}
export default DiscordClient;

View file

@ -1,80 +1,83 @@
const { EventEmitter } = require("events");
import { EventEmitter } from "events";
class WatchedGuild extends EventEmitter {
constructor() {
super();
this.knownWebhooks = new Map();
this.eventStack = [];
this.upstreamGuildId = null;
}
pushEvent(e) {
this.eventStack.push(e);
this.emit("pushed", e);
}
consumeEvent() {
return this.eventStack.pop();
}
consumeAll() {
const events = [...this.eventStack];
this.eventStack = [];
return events;
}
hasEvents() {
return this.eventStack.length > 0;
}
_pushMessageEvent(message) {
this.pushEvent({
eventType: "messageCreate",
message: message.toJSON()
holdForEvent() {
return new Promise((resolve, reject) => {
// potential memory leak here when too many promises are created and waiting
this.once("pushed", (event) => {
resolve(event);
});
});
}
discordConnect(bot) {
this.bot = bot;
this.bot.on("messageCreate", (message) => {
if (message.guildId !== this.upstreamGuildId)
this.guildObject = this.bot.guilds.find(e => e.id === this.upstreamGuildId);
if (!this.guildObject) {
throw new Error("Could not find guild object from bot cache by id (is the upstreamGuildId valid and does the bot have access to it?)");
}
this.bot.on("GUILD_CREATE", (guild) => {
if (guild.id === this.upstreamGuildId)
this.guildObject = guild;
});
this.bot.on("GUILD_UPDATE", (guild) => {
if (guild.id === this.upstreamGuildId)
this.guildObject = guild;
});
this.bot.on("MESSAGE_CREATE", (message) => {
if (message.guild_id !== this.upstreamGuildId)
return;
this._pushMessageEvent(message);
this.pushEvent({
eventType: "MESSAGE_CREATE",
message: message
});
});
}
async discordSendMessage(messageContent, channelId, username, avatarURL=undefined) {
if (!this.bot)
throw new Error("Bot not connected");
userFacingChannelList() {
return this.guildObject.channels.map(channel => ({ id: channel.id, name: channel.name, position: channel.position, type: channel.type, nsfw: channel.nsfw }));
}
async discordSendMessage(content, channelId, username, avatarURL=undefined) {
let webhook = this.knownWebhooks.get(channelId);
if (!webhook) {
webhook = (await this.bot.getChannelWebhooks(channelId))
.filter(w => w.name == "well_known__bridge")[0];
webhook = (await this.bot.api(["GET", `/channels/${channelId}/webhooks`]))
.find(e => e.name === "well_known__bridge");
if (!webhook)
webhook = await this.bot.createChannelWebhook(channelId, {
webhook = await this.bot.api(["POST", `/channels/${channelId}/webhooks`], {
name: "well_known__bridge"
}, "This webhook was created by the bridge API bot.");
});
this.knownWebhooks.set(channelId, webhook);
}
await this.bot.executeWebhook(webhook.id, webhook.token, {
allowedMentions: {
everyone: false,
roles: false,
users: true
},
content: messageContent,
await this.bot.api(["POST", `/webhooks/${webhook.id}/${webhook.token}?wait=true`], {
content,
username,
avatar_url: avatarURL,
tts: false,
wait: true,
avatarURL,
username
allowed_mentions: {
parse: ["users"]
}
});
}
}
module.exports = WatchedGuild;
export default WatchedGuild;

View file

@ -1,17 +1,19 @@
const Eris = require("eris");
const { discordToken, watchedGuildIds } = require("./config");
const WatchedGuild = require("./WatchedGuild");
import { discordToken, watchedGuildIds } from "./config.js";
import DiscordClient from "./DiscordClient.js";
import WatchedGuild from "./WatchedGuild.js";
const bot = new Eris(discordToken, {
intents: [
"guildMessages"
]
export const guildMap = new Map();
export const bot = new DiscordClient(discordToken, {
intents: 0 | (1 << 0) | (1 << 9) // GUILDS & GUILD_MESSAGES
});
const guildMap = new Map();
export function wait(time) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), time);
});
}
bot.on("ready", () => {
console.log("discord bot: ready");
bot.on("READY", () => {
watchedGuildIds.forEach(id => {
const watchedGuild = new WatchedGuild();
watchedGuild.upstreamGuildId = id;
@ -19,10 +21,3 @@ bot.on("ready", () => {
guildMap.set(id, watchedGuild);
});
});
bot.connect();
module.exports = {
bot,
guildMap
};

View file

@ -1,7 +1,5 @@
module.exports = {
mainHttpListenPort: 4050,
watchedGuildIds: ["822089558886842418"],
jwtSecret: process.env.JWT_SECRET,
discordToken: process.env.DISCORD_TOKEN,
dangerousAdminMode: true
};
export const mainHttpListenPort = 4050;
export const watchedGuildIds = ["822089558886842418"];
export const jwtSecret = process.env.JWT_SECRET;
export const discordToken = process.env.DISCORD_TOKEN;
export const dangerousAdminMode = true;

View file

@ -1,7 +1,9 @@
const express = require("express");
const { mainHttpListenPort } = require("./config");
import express from "express";
import apiRoute from "./routes/api.js";
import { mainHttpListenPort } from "./config.js";
import { bot } from "./common.js";
const app = express();
const apiRoute = require("./routes/api");
app.use(express.json());
app.use("/", express.static("public/"));
@ -13,4 +15,5 @@ app.get("/", (req, res) => {
app.listen(mainHttpListenPort, () => {
console.log(`server main: listen on ${mainHttpListenPort}`);
bot.connect();
});

View file

@ -3,9 +3,11 @@
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"type": "module",
"dependencies": {
"eris": "^0.16.1",
"express": "^4.17.2",
"jsonwebtoken": "^8.5.1"
"jsonwebtoken": "^8.5.1",
"node-fetch": "^3.2.0",
"ws": "^8.4.2"
}
}

View file

@ -7,8 +7,11 @@
<title>Document</title>
</head>
<body>
<button onclick="createToken('testinguser', '0', ['0']);">create token</button>
<button onclick="sendMessage('0', '0', 'hello');">send message</button>
<button onclick="createToken('testinguser', '0', ['822089558886842418']);">create token</button>
<button onclick="sendMessage('822089558886842418', '822089558886842421', 'hello');">send message</button>
<button onclick="getChannels('822089558886842418');">get channels</button>
<button onclick="eventPoll('822089558886842418');">poll event</button>
<script>
let createdToken;
@ -41,8 +44,27 @@
"authorization": createdToken
}
});
const json = await res.json();
console.log("sendMessage()", json);
console.log("sendMessage() stauts", await res.status);
}
async function getChannels(guildId) {
const res = await fetch(`http://localhost:4050/api/v1/guilds/${guildId}/channels`, {
method: "GET",
headers: {
"authorization": createdToken
}
});
console.log("getChannels()", await res.json());
}
async function eventPoll(guildId) {
const res = await fetch(`http://localhost:4050/api/v1/guilds/${guildId}/events/poll`, {
method: "GET",
headers: {
"authorization": createdToken
}
});
console.log("eventPoll()", await res.json());
}
</script>
</body>

View file

@ -1,7 +1,8 @@
const express = require("express");
const { guildMap } = require("../common");
const { dangerousAdminMode } = require("../config");
const { checkAuth, createToken } = require("../tokens");
import express from "express";
import { guildMap, wait } from "../common.js";
import { dangerousAdminMode } from "../config.js";
import { checkAuth, createToken } from "../tokens.js";
const router = express();
router.get("/", (req, res) => {
@ -46,11 +47,57 @@ router.post("/guilds/:guildId/channels/:channelId/messages/create", checkAuth(as
const guild = guildMap.get(guildId);
try {
await guild.discordSendMessage(messageContent, channelId, username, avatarURL);
res.status(201).send({ error: false, message: "SUCCESS_MESSAGE_CREATED" });
res.status(201).send("");
} catch(e) {
console.error("server main: api: message create: error: ", e);
res.status(500).send({ error: true, message: "ERROR_MESSAGE_SEND_FAILURE" });
}
}));
module.exports = router;
router.get("/guilds/:guildId/channels", checkAuth(async (req, res) => {
const guildId = req.params.guildId;
if (!guildId)
return res.status(400).send({ error: true, message: "ERROR_NO_GUILD_ID" });
const { guildAccess } = req.user;
if (guildAccess.indexOf(guildId) === -1)
return res.status(403).send({ error: true, message: "ERROR_NO_GUILD_ACCESS" });
const guild = guildMap.get(guildId);
try {
res.status(200).send({ error: false, channels: guild.userFacingChannelList() });
} catch(e) {
console.error("server main: api: guild get channels: error: ", e);
res.status(500).send({ error: true, message: "ERROR_CHANNELS_FETCH_FAILURE" });
}
}));
router.get("/guilds/:guildId/events/poll", checkAuth(async (req, res) => {
const guildId = req.params.guildId;
if (!guildId)
return res.status(400).send({ error: true, message: "ERROR_NO_GUILD_ID" });
const { guildAccess } = req.user;
if (guildAccess.indexOf(guildId) === -1)
return res.status(403).send({ error: true, message: "ERROR_NO_GUILD_ACCESS" });
const guild = guildMap.get(guildId);
try {
Promise.race([
guild.holdForEvent(),
wait(10000)
]).then(event => {
res.status(200).send({ error: false, event });
})
.catch(() => {
res.status(200).send({ error: false, event: null });
});
} catch(e) {
console.error("server main: api: guild poll events: error: ", e);
res.status(500).send({ error: true, message: "ERROR_POLL_FAILURE" });
}
}));
export default router;

View file

@ -1,7 +1,7 @@
const jsonwebtoken = require("jsonwebtoken");
const { jwtSecret } = require("./config");
import jsonwebtoken from "jsonwebtoken";
import { jwtSecret } from "./config.js";
function createToken({ username, avatarURL, discordID, guildAccess }) {
export function createToken({ username, avatarURL, discordID, guildAccess }) {
return new Promise((resolve, reject) => {
jsonwebtoken.sign({ username, avatarURL, discordID, guildAccess }, jwtSecret, (err, token) => {
if (err)
@ -12,7 +12,7 @@ function createToken({ username, avatarURL, discordID, guildAccess }) {
});
}
function decodeToken(token) {
export function decodeToken(token) {
return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, jwtSecret, (err, token) => {
if (err)
@ -23,7 +23,7 @@ function decodeToken(token) {
});
}
function checkAuth(callback) {
export function checkAuth(callback) {
return async (req, res) => {
const token = req.get("authorization");
if (token) {
@ -48,9 +48,3 @@ function checkAuth(callback) {
}
};
}
module.exports = {
createToken,
decodeToken,
checkAuth
};

View file

@ -63,6 +63,11 @@ cookie@0.4.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
data-uri-to-buffer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b"
integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -97,16 +102,6 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
eris@^0.16.1:
version "0.16.1"
resolved "https://registry.yarnpkg.com/eris/-/eris-0.16.1.tgz#44b0a9220944fc73dd74538cd614826bfbfcde61"
integrity sha512-fqjgaddSvUlUjA7s85OvZimLrgCwX58Z6FXOIxdNFJdT6XReJ/LOWZKdew2CaalM8BvN2JKzn98HmKYb3zMhKg==
dependencies:
ws "^8.2.3"
optionalDependencies:
opusscript "^0.0.8"
tweetnacl "^1.0.3"
escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@ -153,6 +148,14 @@ express@^4.17.2:
utils-merge "1.0.1"
vary "~1.1.2"
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.4.tgz#e8c6567f80ad7fc22fd302e7dcb72bafde9c1717"
integrity sha512-Eq5Xv5+VlSrYWEqKrusxY1C3Hm/hjeAsCGVG3ft7pZahlUAChpGZT/Ms1WmSLnEAisEXszjzu/s+ce6HZB2VHA==
dependencies:
node-domexception "^1.0.0"
web-streams-polyfill "^3.0.3"
finalhandler@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
@ -166,6 +169,13 @@ finalhandler@~1.1.2:
statuses "~1.5.0"
unpipe "~1.0.0"
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
dependencies:
fetch-blob "^3.1.2"
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
@ -319,6 +329,20 @@ negotiator@0.6.2:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
node-domexception@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-fetch@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.0.tgz#59390db4e489184fa35d4b74caf5510e8dfbaf3b"
integrity sha512-8xeimMwMItMw8hRrOl3C9/xzU49HV/yE6ORew/l+dxWimO5A4Ra8ld2rerlJvc/O7et5Z1zrWsPX43v1QBjCxw==
dependencies:
data-uri-to-buffer "^4.0.0"
fetch-blob "^3.1.4"
formdata-polyfill "^4.0.10"
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@ -326,11 +350,6 @@ on-finished@~2.3.0:
dependencies:
ee-first "1.1.1"
opusscript@^0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/opusscript/-/opusscript-0.0.8.tgz#00b49e81281b4d99092d013b1812af8654bd0a87"
integrity sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==
parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@ -428,11 +447,6 @@ toidentifier@1.0.1:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
tweetnacl@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@ -456,7 +470,12 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
ws@^8.2.3:
web-streams-polyfill@^3.0.3:
version "3.2.0"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965"
integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==
ws@^8.4.2:
version "8.4.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.4.2.tgz#18e749868d8439f2268368829042894b6907aa0b"
integrity sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==