diff --git a/server/package.json b/server/package.json
index c2ed96bc..e836a77b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -19,6 +19,8 @@
"nodemon": "^2.0.7"
},
"dependencies": {
+ "@discordjs/opus": "^0.9.0",
+ "@discordjs/voice": "^0.13.0",
"@twurple/api": "^5.2.5",
"@twurple/auth": "^5.2.5",
"airtable": "^0.10.1",
@@ -28,6 +30,7 @@
"discord.js": "^13.3.1",
"dotenv": "^8.2.0",
"express": "^4.17.1",
+ "libsodium-wrappers": "^0.7.10",
"node-fetch": "^2.6.1",
"ora": "^5.4.0",
"sharp": "^0.30.1",
diff --git a/server/src/airtable-interface.js b/server/src/airtable-interface.js
index afe253ea..233521f6 100644
--- a/server/src/airtable-interface.js
+++ b/server/src/airtable-interface.js
@@ -82,7 +82,7 @@ function setRebuilding(isRebuilding) {
// Starting with syncing Matches
// const tables = ["Matches", "Teams", "Themes", "Events", "Players", "Player Relationships"];
-const tables = ["Broadcasts", "Clients", "Channels", "Players", "Events", "Event Series", "Teams", "Ad Reads", "Ad Read Groups", "News", "Matches", "Themes", "Socials", "Accolades", "Player Relationships", "Brackets", "Live Guests", "Headlines", "Maps", "Map Data", "Heroes", "Log Files", "Tracks", "Track Groups", "Track Group Roles"];
+const tables = ["Broadcasts", "Clients", "Channels", "Discord Bots", "Players", "Events", "Event Series", "Teams", "Ad Reads", "Ad Read Groups", "News", "Matches", "Themes", "Socials", "Accolades", "Player Relationships", "Brackets", "Live Guests", "Headlines", "Maps", "Map Data", "Heroes", "Log Files", "Tracks", "Track Groups", "Track Group Roles"];
const staticTables = ["Redirects"];
function deAirtable(obj) {
diff --git a/server/src/cache.js b/server/src/cache.js
index 5703ec0a..014dc3ae 100644
--- a/server/src/cache.js
+++ b/server/src/cache.js
@@ -89,10 +89,10 @@ async function dataUpdate(id, data, options) {
if (JSON.stringify(store.get(id)) !== JSON.stringify(data)) {
// console.log(`Data update on [${id}]`);
recents.sent++;
- if (!(options && options.custom)) updateFunction(id, { oldData: store.get(id), newData: data });
if (data) data = await removeAntiLeak(id, data);
// if (options?.eager) console.log("Sending");
await broadcast(id, "data_update", id, data);
+ if (!(options && options.custom)) updateFunction(id, { oldData: store.get(id), newData: data });
}
}
@@ -198,7 +198,13 @@ async function set(id, data, options) {
if (data?.__tableName === "Channels") {
auth.set(`channel_${id}`, data);
- return; // not setting it on global requestble store
+ return; // not setting it on global requestable store
+ }
+
+ if (data?.__tableName === "Discord Bots") {
+ auth.set(`bot_${cleanID(id)}`, data);
+
+ return; // not setting it on global requestable store
}
if (data?.__tableName === "Events") {
@@ -266,6 +272,7 @@ async function set(id, data, options) {
await dataUpdate(id, data, options);
store.set(id, data);
+
}
function cleanID(id) {
if (!id) return null;
@@ -318,12 +325,18 @@ async function getPlayer(discordID) {
async function getChannel(airtableID) {
return auth.get(`channel_${cleanID(airtableID)}`);
}
+async function getBot(airtableID) {
+ return auth.get(`bot_${cleanID(airtableID)}`);
+}
async function getChannelByID(channelID) {
return (await getChannels()).find(channel => channel.channel_id === channelID);
}
async function getChannels() {
return await Promise.all(((await get("Channels"))?.ids || []).map(id => getChannel(id)));
}
+async function getBots() {
+ return await Promise.all(((await get("Discord Bots"))?.ids || []).map(id => getBot(id)));
+}
async function getTwitchAccessToken(channel) {
// get stored access token, check if it's valid
@@ -349,6 +362,7 @@ module.exports = {
getPlayer,
getChannel,
getChannelByID,
- getTwitchAccessToken
+ getTwitchAccessToken,
+ getBots
}
};
diff --git a/server/src/custom-datasets.js b/server/src/custom-datasets.js
index 1b59be59..e9588aee 100644
--- a/server/src/custom-datasets.js
+++ b/server/src/custom-datasets.js
@@ -6,6 +6,7 @@ function tableUpdated(tableName, Cache) {
if (tableName === "Matches") matchUpdate(Cache);
if (tableName === "Broadcasts") broadcastUpdate(Cache);
if (tableName === "Players") playerList(Cache);
+ // TODO: maybe add discord bots here?
}
module.exports = tableUpdated;
diff --git a/server/src/discord/bot-controller.js b/server/src/discord/bot-controller.js
new file mode 100644
index 00000000..0edb8ae7
--- /dev/null
+++ b/server/src/discord/bot-controller.js
@@ -0,0 +1,532 @@
+const {Client, Intents} = require("discord.js");
+const {joinVoiceChannel, EndBehaviorType} = require("@discordjs/voice");
+
+const { onUpdate, auth: { getBots, getPlayer }, get } = require("../cache.js");
+const { MapObject } = require("./managers");
+
+let io;
+
+function cleanID(id) {
+ if (!id) return null;
+ if (typeof id !== "string") return id.id || null; // no real id oops
+ if (id.startsWith("rec") && id.length === 17) id = id.slice(3);
+ return id;
+}
+
+// hivemind time
+
+/**
+ * this is not jsdoc``````
+ *
+ * - get all tokens from airtable "Discord Bots".token
+ * - connect them all to Discord and track them
+ * - keep them in a pool and send them to join voice channels when requested***
+ * - Send out events when players start/stop talking -> show who is talking using team cams position data
+ *
+ * [requested] - what triggers the bots to join?
+ * - Run Airtable query every few seconds that checks for any broadcast with the flag "Enable Teams comms"?
+ * (we can probably hook into the updating signals that are sent out to socket.io)
+ *
+ */
+
+
+/* watch for:
+ - broadcasts with certain settings
+ - those broadcasts changing their matches
+ */
+
+let broadcastIDs = [];
+let watchIDs = [];
+
+async function killTeamComms(broadcastKey) {
+ manager.jobs.filter(job => job.broadcastKey === broadcastKey && ["team1", "team2"].includes(job.taskKey))
+ .forEach(job => {
+ if (!job.client) return;
+ job.client.endJob();
+ });
+ manager.jobs = manager.jobs.filter(job => !(job.broadcastKey === broadcastKey && ["team1", "team2"].includes(job.taskKey)));
+}
+
+
+async function checkBroadcast(id, broadcast) {
+ if (!broadcast) broadcast = await get(id);
+ if (!broadcast?.key || !broadcast?.active || !broadcast.broadcast_settings?.length) return;
+ let broadcastKey = broadcast.key;
+
+ if (broadcast.broadcast_settings.includes("Connect for caster voice")) {
+ let taskKey = "casters";
+ if (!broadcast.voice_channels) return console.warn(`[Voice] Couldn't connect for caster voice because ${broadcast.name} has no voice_channels set.`);
+ let channelIDs = new MapObject(broadcast.voice_channels);
+ if (!channelIDs.get("live")) return console.warn(`[Voice] Couldn't connect for caster voice because ${broadcast.name} has no voice_channels.live set.`);
+ console.log("Creating a new caster job", broadcastKey, channelIDs.get("live"));
+ manager.getOrCreateJob(channelIDs.get("live"), broadcastKey, taskKey);
+ } else {
+ // if there's a job that's bpl4/casters kill it
+ manager.jobs.filter(job => job.broadcastKey === broadcastKey && job.taskKey === "casters")
+ .forEach(job => {
+ if (!job.client) return;
+ job.client.endJob();
+ });
+ manager.jobs = manager.jobs.filter(job => !(job.broadcastKey === broadcastKey && job.taskKey === "casters"));
+ }
+
+ if (broadcast.broadcast_settings.includes("Connect for team comms")) {
+ if (!broadcast.live_match) {
+ killTeamComms(broadcastKey);
+ return console.warn(`[Voice] Couldn't connect for team comms because ${broadcast.name} has no live_match.`);
+ }
+ let matchID = cleanID(broadcast?.live_match?.[0]);
+ watchIDs.push(matchID);
+ let match = await get(matchID);
+ if ((match.teams || []).length !== 2) {
+ killTeamComms(broadcastKey);
+ return console.warn(`[Voice] Couldn't connect for team comms because the ${broadcast.name} live match doesn't have 2 teams.`);
+ }
+ let teamIDs = match.teams.map(team => cleanID(team));
+ watchIDs = [...watchIDs, ...teamIDs];
+ let teams = await Promise.all(teamIDs.map(id => get(id)));
+
+ teams.forEach((team, i) => {
+ let taskKey = `team${i+1}`;
+ let voiceChannels = new MapObject(team.discord_control);
+ let voiceChannelID = voiceChannels.get("voice_channel_id");
+ if (!voiceChannelID) return console.warn(`[Voice] Couldn't connect for team ${team.name} on ${broadcast.name} because they have no voice channel ID listed.`);
+ manager.getOrCreateJob(voiceChannelID, broadcastKey, taskKey, team.name);
+
+ });
+ } else {
+ killTeamComms(broadcastKey);
+
+ }
+}
+
+onUpdate(async(id, { newData, oldData }) => {
+ setTimeout(async () => {
+ if (id === "Broadcasts") {
+ broadcastIDs = newData.ids.map(id => cleanID(id));
+ // check all broadcasts
+ broadcastIDs.map(id => checkBroadcast(id));
+ }
+
+ if (broadcastIDs.includes(cleanID(id))) {
+ checkBroadcast(id, newData);
+ // check broadcast ID
+ }
+ if (watchIDs.includes(cleanID(id))) {
+ broadcastIDs.map(id => checkBroadcast(id));
+ }
+
+ if (id === "Discord Bots") {
+ let botData = await getBots(); // update manager?
+ manager.setTokens(botData.map(d => d.token).filter(d => d));
+
+ // manager.createJob("996236081819303936", "bpl4", "assistance");
+ }
+ }, 100);
+});
+
+
+/**
+ * @typedef Job
+ * @param channelID {Snowflake}
+ * @param status {("unfulfilled"|"working")}
+ * @param client {DiscordBot}
+ */
+
+class Job {
+ constructor({ channelID, broadcastKey, taskKey, team}) {
+ this.channelID = channelID;
+ this.broadcastKey = broadcastKey;
+ this.taskKey = taskKey;
+ this.team = team;
+ this.teamName = team?.name;
+
+ this.client = null;
+ this.status = "unfulfilled";
+ }
+
+ sync(customSocket) {
+ if (!io) return;
+ let audioRoom = `${this.broadcastKey}/${this.taskKey}`;
+ let destination = customSocket || io.to(audioRoom);
+ destination.emit("audio_job_status", audioRoom, this.serialize());
+ }
+
+ serialize() {
+ return ({
+ status: this.status,
+ channelID: this.channelID,
+ broadcastKey: this.broadcastKey,
+ taskKey: this.taskKey,
+ teamID: this.team?.id
+ });
+ }
+}
+
+
+/***
+ * @property jobs {Job[]}
+ * @property clients {DiscordBot[]}
+ */
+class BotManager {
+ constructor() {
+ this.clients = [];
+ this.jobs = [];
+
+ // setInterval(() => this.printStatus(), 15 * 1000);
+ }
+
+ setTokens(tokens) {
+ tokens.forEach(token => {
+ this.createClient(token);
+ });
+ // not join vc just connect to discord
+ // possibly also check to see if they
+ }
+
+ createClient(token) {
+ if (this.clients.some(client => client.token === token)) return;
+ this.clients.push(new DiscordBot({ botToken: token, manager: this }));
+ }
+
+ createJob(channelID, broadcastKey, taskKey, team) {
+ console.log(`[BotManager] New job [${broadcastKey}/${taskKey}] created for channel ${channelID}`);
+ let job = new Job({
+ channelID,
+ broadcastKey,
+ taskKey,
+ team
+ });
+ this.jobs.push(job);
+ this.assignJobs();
+ job.sync();
+ return job;
+ }
+
+ getOrCreateJob(channelID, broadcastKey, taskKey, team) {
+ let job = this.jobs.find(job => job.broadcastKey === broadcastKey && job.taskKey === taskKey);
+ if (job) {
+ if (job.status === "unfulfilled" || !job.client) return job;
+
+ if (job.channelID === channelID) {
+ // requested job is same channel
+ return job;
+ } else {
+ // requested job is different channel
+ job.client.endJob(); // ? TODO: maybe?
+ }
+ }
+ return this.createJob(channelID, broadcastKey, taskKey, team);
+ }
+
+ getClient(taskKey, broadcastKey) {
+ return this.clients.find(client => client?.job?.broadcastKey === broadcastKey && client?.job?.taskKey === taskKey);
+ }
+ getJob(taskKey, broadcastKey) {
+ return this.jobs.find(job => job?.broadcastKey === broadcastKey && job?.taskKey === taskKey);
+ }
+
+ printStatus() {
+ // return;
+ console.log("jobs", this.jobs.map(job => ({
+ status: job.status,
+ channelID: job.channelID
+ })));
+ console.log("clients", this.clients.map(client => ({
+ token: client.token.slice(-6),
+ status: client.status,
+ tag: client.discord.tag,
+ job: client.job && {
+ status: client.job.status,
+ channelID: client.job.channelID,
+ broadcastKey: client.job.broadcastKey,
+ taskKey: client.job.taskKey
+ }
+ })));
+ }
+
+ assignJobs() {
+ // this.printStatus();
+
+ this.jobs.filter(job => job.status === "unfulfilled").forEach(job => {
+ let client = this.clients.find(client => client.status === "ready");
+ if (!client) return console.log("[BotManager] No client available for job", job);
+ client.setJob(job);
+ job.status = "attempting";
+ job.client = client;
+ job.sync();
+ // this.printStatus();
+ });
+ }
+
+ setWorkerJobStatus(worker, status) {
+ let job = this.jobs.find(job => job.client.token === worker.token);
+ if (!job) return console.warn(`[BotManager] No job for this worker ${worker.id}`);
+ job.status = status;
+ job.sync();
+ // this.printStatus();
+ }
+
+ deleteWorkerJob(worker) {
+ let job = this.jobs.find(job => job.client.token === worker.token);
+ // job.prepareForDeletion();
+ if (!job) return console.error("Tried to delete a worker's job but couldn't find it");
+ this.jobs = this.jobs.filter(job => !(job.client.token === worker.token));
+ }
+
+}
+
+const manager = new BotManager();
+
+class DiscordBot {
+ constructor({ botToken, manager }) {
+ this.manager = manager;
+ this.token = botToken;
+ this.id = this.token.slice(-6);
+ this.status = "connecting";
+
+ this.log("New bot setting up");
+
+ this.channel = null;
+ this.discord = {
+ tag: null,
+ id: null
+ };
+ this.connection = null;
+ this.audios = [];
+
+ this.memberList = new MemberList(this);
+
+ this.client = new Client({
+ intents: [
+ Intents.FLAGS.GUILDS,
+ Intents.FLAGS.GUILD_VOICE_STATES
+ ]
+ });
+
+ this.client.once("ready", async () => {
+ this.discord = {
+ tag: this.client.user.tag,
+ id: this.client.user.id
+ };
+ this.setStatus("ready");
+ this.log(`Connected new bot as ${this.client.user.tag}`);
+ });
+
+ this.client.on("voiceStateUpdate", async (oldState, newState) => {
+
+ if (newState.member.user.id === this.discord.id) {
+ return this.checkJob(newState.channelId);
+ }
+
+ if (!this.job?.channelID || !this.connection) return;
+ if (oldState.channelId === this.job?.channelID || newState.channelId === this.job?.channelID) {
+ this.memberList.updateMembers(this.channel.members);
+ }
+ if (newState.channelId === this.job?.channelID) {
+ this.subscribeUserAudio(newState.member);
+ }
+ });
+
+ this.client.on("debug", (m) => {
+ // this.log(m);
+ });
+
+ this.client.login(botToken);
+ }
+
+ log(...str) {
+ console.log(`[Bot-${this.id}] [${this.status}${this.socketRoom ? ":" + this.socketRoom : ""}] ${this.discord?.tag || ""} ${str.join(" ")}`);
+ }
+
+ setStatus(status) {
+ this.status = status;
+ switch (status) {
+ case "working":
+ this.client.user.setPresence({ activities: [{ name: `${this.job?.teamName ? `${this.job?.teamName}'s` : "player"} comms`, type: "LISTENING" }], status: "online" });
+ break;
+ case "lost":
+ this.client.user.setPresence({ activities: [{ name: "in the wrong channel", type: "COMPETING" }], status: "idle" });
+ break;
+ case "attempting":
+ this.client.user.setPresence({ activities: [{ name: "in joining the correct channel", type: "COMPETING" }], status: "online" });
+ break;
+ case "ready":
+ case "disconnected":
+ default:
+ this.client.user.setPresence({ activities: [{ name: "for new tasks", type: "WATCHING" }], status: "idle" });
+ break;
+ }
+
+ this.manager.assignJobs();
+ }
+
+ setJob(job) {
+ this.status = "attempting";
+ this.job = job;
+ this.socketRoom = `${this.job.broadcastKey}/${this.job.taskKey}`;
+ this.log(`Taking new job in channel ${this.job.channelID}`);
+ this.connect();
+ }
+
+ endJob() {
+ this.log("Ending job.");
+ this.status = "ending";
+ this.manager.setWorkerJobStatus(this, "ending");
+ this.disconnect();
+ }
+
+ checkJob(currentChannelID) {
+ if (!this.job) return this.log(`No job but currently in channel ${currentChannelID}`);
+
+ this.log(`Current job is ${this.socketRoom} ${this.job.broadcastKey}/${this.job.taskKey} ${this.job.channelID}`);
+
+ if (this.job.channelID === currentChannelID) {
+ this.setStatus("working");
+ this.log(`Found the right channel and is working ${currentChannelID}`);
+ this.manager.setWorkerJobStatus(this, "working");
+ } else if (this.job.channelID && currentChannelID) {
+ this.setStatus("lost");
+ this.log(`In the wrong channel (should be ${this.job.channelID} but is in ${currentChannelID})`);
+ this.connect(); // this might cause some bugs so use carefully
+ } else {
+ // this.setStatus("disconnected");
+ this.log("No longer in a channel");
+ // this.connect();
+ // this.disconnect();
+ }
+ }
+
+
+ async connect() {
+ this.channel = await this.client.channels.resolve(this.job.channelID);
+ if (!this.channel) {
+ this.setStatus("errored");
+ return this.log("Can't find channel associated with job");
+ }
+ this.log("Connecting to", this.job.channelID, this.channel.name, this.channel.id);
+ if (this.connection && this.connection.state.status !== "destroyed") this.connection.destroy();
+
+ this.connection = joinVoiceChannel({
+ channelId: this.job.channelID,
+ guildId: this.channel.guild.id,
+ adapterCreator: this.channel.guild.voiceAdapterCreator,
+ selfDeaf: false,
+ group: this.client.user.id
+ });
+
+ this.connection.receiver.speaking.on("start", (userId) => {
+ this.memberList.setSpeaking(userId, true);
+ });
+ this.connection.receiver.speaking.on("end", (userId) => {
+ this.memberList.setSpeaking(userId, false);
+ });
+ this.channel.members.forEach(member => this.subscribeUserAudio(member));
+ this.checkJob();
+ this.memberList.updateMembers(this.channel.members);
+ this.memberList.sync();
+ }
+
+ async subscribeUserAudio(member) {
+ if (member.user.id === this.client.user.id) return;
+
+ if (this.audios[member.user.id]) {
+ // console.log("Already subscribed for this user", this.audios[member.user.id]);
+ return;
+ }
+
+ console.log(`Subscribing to audio from [${member.user.id}] ${member.user.username}`);
+
+ const audio = this.connection.receiver.subscribe(member.user.id, {end: {behavior: EndBehaviorType.Manual}});
+
+ audio.on("data", (data) => {
+ // TODO: checks before transmitting data
+ // TODO: How do we manage sockets
+ // console.log("emitting", this.socketRoom, member.user.id);
+ io.to(this.socketRoom).emit("audio", this.socketRoom, {data, user: member.user.id});
+ });
+
+ ["close", "end", "error", "pause"].forEach(eventType => {
+ audio.on(eventType, (...data) => console.log("[Stream]", eventType, member.user.id, data));
+ // TODO: if this Readable pipe is closed then we can unset this.audios[member.user.id] so it reconnects the user
+ });
+
+ this.audios[member.user.id] = audio;
+ }
+
+ async disconnect() {
+ // TODO: implement
+ this.connection.disconnect();
+ this.setStatus("ready");
+ this.manager.deleteWorkerJob(this);
+ }
+ async destroy() {
+ // TODO: implement
+ this.connection.disconnect();
+ this.connection.destroy();
+ this.client.destroy();
+ }
+}
+
+class MemberList {
+ constructor(bot) {
+ this.bot = bot;
+ this.speaking = new Map();
+ this.members = [];
+ }
+
+ async getList() {
+ return await Promise.all(this.members.map(async member => ({
+ ...member,
+ speaking: this.speaking.get(member.id) || false,
+ airtableID: (await getPlayer(member.id))?.id || null
+ })));
+ }
+
+ setSpeaking(userID, state) {
+ this.speaking.set(userID, state);
+ this.sync();
+ }
+
+ updateMembers(discordMembers) {
+ this.members = discordMembers.filter(m => !m.user.bot).map(member => ({
+ name: member.name,
+ id: member.id,
+ tag: member.tag
+ }));
+ this.sync();
+ }
+
+ async sync(customSocket) {
+ let destination = customSocket || io.to(this.bot.socketRoom);
+ // this.bot.log("updating member list");
+ destination.emit("audio_member_list", this.bot.socketRoom, await this.getList());
+ }
+}
+
+module.exports = {
+ setup(_io) {
+ io = _io;
+
+ io.on("connect", socket => {
+ socket.on("audio_subscribe", ({ taskKey, broadcastKey }) => {
+ console.log("[audio] sub", taskKey, broadcastKey);
+ let audioRoom = `${broadcastKey}/${taskKey}`;
+ // if (socket._audioRoom) socket.leave(socket._audioRoom);
+ // socket._audioRoom = `${broadcastKey}/${taskKey}`;
+ socket.join(audioRoom);
+
+ let client = manager.getClient(taskKey, broadcastKey);
+ if (client) {
+ client.memberList.sync(socket);
+ }
+ let job = manager.getJob(taskKey, broadcastKey);
+ if (job) {
+ job.sync(socket);
+ } else {
+ socket.emit("audio_job_status", audioRoom, null);
+ }
+ });
+ });
+ }
+};
diff --git a/server/src/index.js b/server/src/index.js
index d6f7fc3d..f1398598 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -51,6 +51,7 @@ const auction = require("./discord/new_auction.js")({
const Cache = (require("./cache.js")).setup(io);
(require("./airtable-interface.js")).setup(io);
+(require("./discord/bot-controller.js")).setup(io);
const actions = require("./action-manager.js");
actions.load(app, localCors, Cache, io);
@@ -136,7 +137,7 @@ io.on("connection", (socket) => {
console.log("get and subscribe out:", id);
});
socket.on("prod-join", (clientName) => {
- console.log("[prod] join ", clientName);
+ console.log("[prod] join", clientName);
socket._clientName = clientName;
socket.join(`prod:client-${clientName}`);
});
diff --git a/server/yarn.lock b/server/yarn.lock
index 792b6c28..47ccbf15 100644
--- a/server/yarn.lock
+++ b/server/yarn.lock
@@ -105,6 +105,40 @@
resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.7.0.tgz#1a6c00198b744ba2b73a64442145da637ac073b8"
integrity sha512-R5i8Wb8kIcBAFEPLLf7LVBQKBDYUL+ekb23sOgpkpyGT+V4P7V83wTxcsqmX+PbqHt4cEHn053uMWfRqh/Z/nA==
+"@discordjs/node-pre-gyp@^0.4.5":
+ version "0.4.5"
+ resolved "https://registry.yarnpkg.com/@discordjs/node-pre-gyp/-/node-pre-gyp-0.4.5.tgz#b33e38cedd821268c75923641783c68fcd1b55ae"
+ integrity sha512-YJOVVZ545x24mHzANfYoy0BJX5PDyeZlpiJjDkUBM/V/Ao7TFX9lcUvCN4nr0tbr5ubeaXxtEBILUrHtTphVeQ==
+ dependencies:
+ detect-libc "^2.0.0"
+ https-proxy-agent "^5.0.0"
+ make-dir "^3.1.0"
+ node-fetch "^2.6.7"
+ nopt "^5.0.0"
+ npmlog "^5.0.1"
+ rimraf "^3.0.2"
+ semver "^7.3.5"
+ tar "^6.1.11"
+
+"@discordjs/opus@^0.9.0":
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/@discordjs/opus/-/opus-0.9.0.tgz#bbd9f78bd6bc399885bcb48645c6d2b1f0efa0c5"
+ integrity sha512-NEE76A96FtQ5YuoAVlOlB3ryMPrkXbUCTQICHGKb8ShtjXyubGicjRMouHtP1RpuDdm16cDa+oI3aAMo1zQRUQ==
+ dependencies:
+ "@discordjs/node-pre-gyp" "^0.4.5"
+ node-addon-api "^5.0.0"
+
+"@discordjs/voice@^0.13.0":
+ version "0.13.0"
+ resolved "https://registry.yarnpkg.com/@discordjs/voice/-/voice-0.13.0.tgz#dd7eb490246ce00ccdad486859b9ccc4ef275ac9"
+ integrity sha512-ZzwDmVINaLgkoDUeTJfpN9TkjINMLfTVoLMtEygm0YC5jTTw7AvKGqhc+Ae/2kNLywd0joyFVNrLp94yCkQ9SA==
+ dependencies:
+ "@types/ws" "^8.5.3"
+ discord-api-types "^0.37.12"
+ prism-media "^1.3.4"
+ tslib "^2.4.0"
+ ws "^8.9.0"
+
"@eslint/eslintrc@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547"
@@ -275,6 +309,13 @@ acorn@^7.4.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+agent-base@6:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+ integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+ dependencies:
+ debug "4"
+
airtable@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/airtable/-/airtable-0.10.1.tgz#0b311002bb44b39f19bf7c4bd2d47d75c733bf87"
@@ -365,6 +406,19 @@ aproba@^1.0.3:
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+"aproba@^1.0.3 || ^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
+ integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
+
+are-we-there-yet@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c"
+ integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==
+ dependencies:
+ delegates "^1.0.0"
+ readable-stream "^3.6.0"
+
are-we-there-yet@~1.1.2:
version "1.1.7"
resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146"
@@ -574,6 +628,11 @@ chownr@^1.1.1:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+chownr@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
+ integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
+
ci-info@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
@@ -645,6 +704,11 @@ color-string@^1.9.0:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
+color-support@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
+ integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
+
color@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/color/-/color-4.2.1.tgz#498aee5fce7fc982606c8875cab080ac0547c884"
@@ -682,7 +746,7 @@ configstore@^5.0.1:
write-file-atomic "^3.0.0"
xdg-basedir "^4.0.0"
-console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
@@ -748,6 +812,13 @@ debug@2.6.9, debug@^2.2.0:
dependencies:
ms "2.0.0"
+debug@4:
+ version "4.3.4"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+ integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+ dependencies:
+ ms "2.1.2"
+
debug@^3.2.6:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -846,6 +917,11 @@ discord-api-types@^0.36.2:
resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.36.3.tgz#a931b7e57473a5c971d6937fa5f392eb30047579"
integrity sha512-bz/NDyG0KBo/tY14vSkrwQ/n3HKPf87a0WFW/1M9+tXYK+vp5Z5EksawfCWo2zkAc6o7CClc0eff1Pjrqznlwg==
+discord-api-types@^0.37.12:
+ version "0.37.18"
+ resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.37.18.tgz#1f0ca95cea4b2380ba77623a62b1d285f1237d7a"
+ integrity sha512-mJ+9C8gmG5csssVZPH06Y8IGiJykljFyZc6n6F+T3vKo6yNBI5TtLIbwt6t9hJzsR5f1ITzRZ6cuPrTvRCUxqA==
+
discord.js@^13.3.1:
version "13.12.0"
resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.12.0.tgz#e4839c14a02b1947e063b72f09a49b11336a58f5"
@@ -1262,6 +1338,13 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+fs-minipass@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
+ integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
+ dependencies:
+ minipass "^3.0.0"
+
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -1297,6 +1380,21 @@ functions-have-names@^1.2.2:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
+gauge@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395"
+ integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==
+ dependencies:
+ aproba "^1.0.3 || ^2.0.0"
+ color-support "^1.1.2"
+ console-control-strings "^1.0.0"
+ has-unicode "^2.0.1"
+ object-assign "^4.1.1"
+ signal-exit "^3.0.0"
+ string-width "^4.2.3"
+ strip-ansi "^6.0.1"
+ wide-align "^1.1.2"
+
gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@@ -1457,7 +1555,7 @@ has-tostringtag@^1.0.0:
dependencies:
has-symbols "^1.0.2"
-has-unicode@^2.0.0:
+has-unicode@^2.0.0, has-unicode@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
@@ -1501,6 +1599,14 @@ http-errors@~1.7.2:
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
+https-proxy-agent@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+ integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+ dependencies:
+ agent-base "6"
+ debug "4"
+
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -1862,6 +1968,18 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
+libsodium-wrappers@^0.7.10:
+ version "0.7.10"
+ resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz#13ced44cacb0fc44d6ac9ce67d725956089ce733"
+ integrity sha512-pO3F1Q9NPLB/MWIhehim42b/Fwb30JNScCNh8TcQ/kIc+qGLQch8ag8wb0keK3EP5kbGakk1H8Wwo7v+36rNQg==
+ dependencies:
+ libsodium "^0.7.0"
+
+libsodium@^0.7.0:
+ version "0.7.10"
+ resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.10.tgz#c2429a7e4c0836f879d701fec2c8a208af024159"
+ integrity sha512-eY+z7hDrDKxkAK+QKZVNv92A5KYkxfvIshtBJkmg5TSiCnYqZP3i9OO9whE79Pwgm4jGaoHgkM4ao/b9Cyu4zQ==
+
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
@@ -1912,7 +2030,7 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
-make-dir@^3.0.0:
+make-dir@^3.0.0, make-dir@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
@@ -1990,11 +2108,31 @@ minimist@^1.2.0, minimist@^1.2.3:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
+minipass@^3.0.0:
+ version "3.3.4"
+ resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae"
+ integrity sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==
+ dependencies:
+ yallist "^4.0.0"
+
+minizlib@^2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
+ integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
+ dependencies:
+ minipass "^3.0.0"
+ yallist "^4.0.0"
+
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+mkdirp@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+ integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -2042,6 +2180,11 @@ node-addon-api@^4.3.0:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f"
integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==
+node-addon-api@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.0.0.tgz#7d7e6f9ef89043befdb20c1989c905ebde18c501"
+ integrity sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==
+
node-fetch@2.6.7, node-fetch@^2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
@@ -2070,6 +2213,13 @@ nodemon@^2.0.7:
undefsafe "^2.0.3"
update-notifier "^4.1.0"
+nopt@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
+ integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
+ dependencies:
+ abbrev "1"
+
nopt@~1.0.10:
version "1.0.10"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
@@ -2097,12 +2247,22 @@ npmlog@^4.0.1:
gauge "~2.7.3"
set-blocking "~2.0.0"
+npmlog@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0"
+ integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==
+ dependencies:
+ are-we-there-yet "^2.0.0"
+ console-control-strings "^1.1.0"
+ gauge "^3.0.0"
+ set-blocking "^2.0.0"
+
number-is-nan@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-object-assign@^4, object-assign@^4.1.0:
+object-assign@^4, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@@ -2251,6 +2411,11 @@ prepend-http@^2.0.0:
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
+prism-media@^1.3.4:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-1.3.4.tgz#7951f26a9186b791dc8c820ff07310ec46a8a5f1"
+ integrity sha512-eW7LXORkTCQznZs+eqe9VjGOrLBxcBPXgNyHXMTSRVhphvd/RrxgIR7WaWt4fkLuhshcdT5KHL88LAfcvS3f5g==
+
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -2337,7 +2502,7 @@ readable-stream@^2.0.6:
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
-readable-stream@^3.1.1, readable-stream@^3.4.0:
+readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@@ -2490,7 +2655,7 @@ serve-static@1.14.1:
parseurl "~1.3.3"
send "0.17.1"
-set-blocking@~2.0.0:
+set-blocking@^2.0.0, set-blocking@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
@@ -2623,7 +2788,7 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"
-"string-width@^1.0.2 || 2 || 3 || 4":
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -2770,6 +2935,18 @@ tar-stream@^2.1.4:
inherits "^2.0.3"
readable-stream "^3.1.1"
+tar@^6.1.11:
+ version "6.1.12"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.12.tgz#3b742fb05669b55671fb769ab67a7791ea1a62e6"
+ integrity sha512-jU4TdemS31uABHd+Lt5WEYJuzn+TJTCBLljvIAHZOz6M9Os5pJ4dD+vRFLxPa/n3T0iEFzpi+0x1UfuDZYbRMw==
+ dependencies:
+ chownr "^2.0.0"
+ fs-minipass "^2.0.0"
+ minipass "^3.0.0"
+ minizlib "^2.1.1"
+ mkdirp "^1.0.3"
+ yallist "^4.0.0"
+
term-size@^2.1.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54"
@@ -2978,7 +3155,7 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
-wide-align@^1.1.0:
+wide-align@^1.1.0, wide-align@^1.1.2:
version "1.1.5"
resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3"
integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==
diff --git a/website/package.json b/website/package.json
index 660dbfd4..1b8acfed 100644
--- a/website/package.json
+++ b/website/package.json
@@ -14,6 +14,7 @@
"howler": "^2.2.3",
"jimp": "^0.16.1",
"marked": "^2.1.3",
+ "opus-decoder": "^0.5.3",
"socket.io-client": "^4.0.1",
"spacetime": "^6.16.0",
"spacetime-informal": "^0.6.1",
diff --git a/website/src/components/broadcast/ListenInBug.vue b/website/src/components/broadcast/ListenInBug.vue
new file mode 100644
index 00000000..5da2296c
--- /dev/null
+++ b/website/src/components/broadcast/ListenInBug.vue
@@ -0,0 +1,39 @@
+
+