UNPKG

discord-voip

Version:

Discord VoIP library used by discord-player

1,474 lines (1,459 loc) 280 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/index.ts var src_exports = {}; __export(src_exports, { AudioPlayer: () => AudioPlayer, AudioPlayerError: () => AudioPlayerError, AudioPlayerStatus: () => AudioPlayerStatus, AudioResource: () => AudioResource, NoSubscriberBehavior: () => NoSubscriberBehavior, PlayerSubscription: () => PlayerSubscription, StreamType: () => StreamType, VoiceConnection: () => VoiceConnection, VoiceConnectionDisconnectReason: () => VoiceConnectionDisconnectReason, VoiceConnectionStatus: () => VoiceConnectionStatus, createAudioPlayer: () => createAudioPlayer, createAudioResource: () => createAudioResource, entersState: () => entersState, getGroups: () => getGroups, getVoiceConnection: () => getVoiceConnection, getVoiceConnections: () => getVoiceConnections, joinVoiceChannel: () => joinVoiceChannel, version: () => version }); module.exports = __toCommonJS(src_exports); // src/VoiceConnection.ts var import_node_events4 = require("events"); // src/DataStore.ts var import_v10 = require("discord-api-types/v10"); function createJoinVoiceChannelPayload(config) { return { op: import_v10.GatewayOpcodes.VoiceStateUpdate, // eslint-disable-next-line id-length d: { guild_id: config.guildId, channel_id: config.channelId, self_deaf: config.selfDeaf, self_mute: config.selfMute } }; } __name(createJoinVoiceChannelPayload, "createJoinVoiceChannelPayload"); var groups = /* @__PURE__ */ new Map(); groups.set("default", /* @__PURE__ */ new Map()); function getOrCreateGroup(group) { const existing = groups.get(group); if (existing) return existing; const map = /* @__PURE__ */ new Map(); groups.set(group, map); return map; } __name(getOrCreateGroup, "getOrCreateGroup"); function getGroups() { return groups; } __name(getGroups, "getGroups"); function getVoiceConnections(group = "default") { return groups.get(group); } __name(getVoiceConnections, "getVoiceConnections"); function getVoiceConnection(guildId, group = "default") { return getVoiceConnections(group)?.get(guildId); } __name(getVoiceConnection, "getVoiceConnection"); function untrackVoiceConnection(voiceConnection) { return getVoiceConnections(voiceConnection.joinConfig.group)?.delete( voiceConnection.joinConfig.guildId ); } __name(untrackVoiceConnection, "untrackVoiceConnection"); function trackVoiceConnection(voiceConnection) { return getOrCreateGroup(voiceConnection.joinConfig.group).set( voiceConnection.joinConfig.guildId, voiceConnection ); } __name(trackVoiceConnection, "trackVoiceConnection"); var FRAME_LENGTH = 20; var audioCycleInterval; var nextTime = -1; var audioPlayers = []; function audioCycleStep() { if (nextTime === -1) return; nextTime += FRAME_LENGTH; const available = audioPlayers.filter((player) => player.checkPlayable()); for (const player of available) { player["_stepDispatch"](); } prepareNextAudioFrame(available); } __name(audioCycleStep, "audioCycleStep"); function prepareNextAudioFrame(players) { const nextPlayer = players.shift(); if (!nextPlayer) { if (nextTime !== -1) { audioCycleInterval = setTimeout( () => audioCycleStep(), nextTime - Date.now() ); } return; } nextPlayer["_stepPrepare"](); setImmediate(() => prepareNextAudioFrame(players)); } __name(prepareNextAudioFrame, "prepareNextAudioFrame"); function hasAudioPlayer(target) { return audioPlayers.includes(target); } __name(hasAudioPlayer, "hasAudioPlayer"); function addAudioPlayer(player) { if (hasAudioPlayer(player)) return player; audioPlayers.push(player); if (audioPlayers.length === 1) { nextTime = Date.now(); setImmediate(() => audioCycleStep()); } return player; } __name(addAudioPlayer, "addAudioPlayer"); function deleteAudioPlayer(player) { const index = audioPlayers.indexOf(player); if (index === -1) return; audioPlayers.splice(index, 1); if (audioPlayers.length === 0) { nextTime = -1; if (audioCycleInterval !== void 0) clearTimeout(audioCycleInterval); } } __name(deleteAudioPlayer, "deleteAudioPlayer"); // src/networking/Networking.ts var import_node_buffer3 = require("buffer"); var import_node_events3 = require("events"); var import_node_crypto = __toESM(require("crypto")); var import_v42 = require("discord-api-types/voice/v4"); // src/util/Secretbox.ts var import_node_buffer = require("buffer"); var libs = { // eslint-disable-next-line @typescript-eslint/no-explicit-any "sodium-native": /* @__PURE__ */ __name((sodium) => ({ crypto_aead_xchacha20poly1305_ietf_encrypt: /* @__PURE__ */ __name((plaintext, additionalData, nonce2, key) => { const cipherText = import_node_buffer.Buffer.alloc( plaintext.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES ); sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( cipherText, plaintext, additionalData, null, nonce2, key ); return cipherText; }, "crypto_aead_xchacha20poly1305_ietf_encrypt") }), "sodium-native"), // eslint-disable-next-line @typescript-eslint/no-explicit-any sodium: /* @__PURE__ */ __name((sodium) => ({ crypto_aead_xchacha20poly1305_ietf_encrypt: /* @__PURE__ */ __name((plaintext, additionalData, nonce2, key) => { return sodium.api.crypto_aead_xchacha20poly1305_ietf_encrypt( plaintext, additionalData, null, nonce2, key ); }, "crypto_aead_xchacha20poly1305_ietf_encrypt") }), "sodium"), // eslint-disable-next-line @typescript-eslint/no-explicit-any "libsodium-wrappers": /* @__PURE__ */ __name((sodium) => ({ crypto_aead_xchacha20poly1305_ietf_encrypt: /* @__PURE__ */ __name((plaintext, additionalData, nonce2, key) => { return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( plaintext, additionalData, null, nonce2, key ); }, "crypto_aead_xchacha20poly1305_ietf_encrypt") }), "libsodium-wrappers"), // eslint-disable-next-line @typescript-eslint/no-explicit-any "@stablelib/xchacha20poly1305": /* @__PURE__ */ __name((stablelib) => ({ crypto_aead_xchacha20poly1305_ietf_encrypt(cipherText, additionalData, nonce2, key) { const crypto2 = new stablelib.XChaCha20Poly1305(key); return crypto2.seal(nonce2, cipherText, additionalData); } }), "@stablelib/xchacha20poly1305"), // eslint-disable-next-line @typescript-eslint/no-explicit-any "@noble/ciphers/chacha": /* @__PURE__ */ __name((noble) => ({ crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, additionalData, nonce2, key) { const chacha = noble.xchacha20poly1305(key, nonce2, additionalData); return chacha.encrypt(plaintext); } }), "@noble/ciphers/chacha") }; libs["sodium-javascript"] = libs["sodium-native"]; var validLibs = Object.keys(libs); var fallbackError = /* @__PURE__ */ __name(() => { throw new Error( `Cannot play audio as no valid encryption package is installed. - Install one of the following packages: ${validLibs.join(", ")} - Use the generateDependencyReport() function for more information. ` ); }, "fallbackError"); var methods = { crypto_aead_xchacha20poly1305_ietf_encrypt: fallbackError }; void (async () => { for (const libName of Object.keys(libs)) { try { const lib = await import(libName); if (libName === "libsodium-wrappers" && lib.ready) await lib.ready; Object.assign(methods, libs[libName](lib)); break; } catch { } } })(); // src/util/util.ts var noop = /* @__PURE__ */ __name(() => { }, "noop"); // src/networking/VoiceUDPSocket.ts var import_node_buffer2 = require("buffer"); var import_node_dgram = require("dgram"); var import_node_events = require("events"); var import_node_net = require("net"); function parseLocalPacket(message) { const packet = import_node_buffer2.Buffer.from(message); const ip = packet.slice(8, packet.indexOf(0, 8)).toString("utf8"); if (!(0, import_node_net.isIPv4)(ip)) { throw new Error("Malformed IP address"); } const port = packet.readUInt16BE(packet.length - 2); return { ip, port }; } __name(parseLocalPacket, "parseLocalPacket"); var KEEP_ALIVE_INTERVAL = 5e3; var MAX_COUNTER_VALUE = 2 ** 32 - 1; var _VoiceUDPSocket = class _VoiceUDPSocket extends import_node_events.EventEmitter { /** * Creates a new VoiceUDPSocket. * * @param remote - Details of the remote socket */ constructor(remote) { super(); /** * The underlying network Socket for the VoiceUDPSocket. */ __publicField(this, "socket"); /** * The socket details for Discord (remote) */ __publicField(this, "remote"); /** * The counter used in the keep alive mechanism. */ __publicField(this, "keepAliveCounter", 0); /** * The buffer used to write the keep alive counter into. */ __publicField(this, "keepAliveBuffer"); /** * The Node.js interval for the keep-alive mechanism. */ __publicField(this, "keepAliveInterval"); /** * The time taken to receive a response to keep alive messages. * * @deprecated This field is no longer updated as keep alive messages are no longer tracked. */ __publicField(this, "ping"); this.socket = (0, import_node_dgram.createSocket)("udp4"); this.socket.on("error", (error) => this.emit("error", error)); this.socket.on("message", (buffer) => this.onMessage(buffer)); this.socket.on("close", () => this.emit("close")); this.remote = remote; this.keepAliveBuffer = import_node_buffer2.Buffer.alloc(8); this.keepAliveInterval = setInterval( () => this.keepAlive(), KEEP_ALIVE_INTERVAL ); setImmediate(() => this.keepAlive()); } /** * Called when a message is received on the UDP socket. * * @param buffer - The received buffer */ onMessage(buffer) { this.emit("message", buffer); } /** * Called at a regular interval to check whether we are still able to send datagrams to Discord. */ keepAlive() { this.keepAliveBuffer.writeUInt32LE(this.keepAliveCounter, 0); this.send(this.keepAliveBuffer); this.keepAliveCounter++; if (this.keepAliveCounter > MAX_COUNTER_VALUE) { this.keepAliveCounter = 0; } } /** * Sends a buffer to Discord. * * @param buffer - The buffer to send */ send(buffer) { this.socket.send(buffer, this.remote.port, this.remote.ip); } /** * Closes the socket, the instance will not be able to be reused. */ destroy() { try { this.socket.close(); } catch { } clearInterval(this.keepAliveInterval); } /** * Performs IP discovery to discover the local address and port to be used for the voice connection. * * @param ssrc - The SSRC received from Discord */ async performIPDiscovery(ssrc) { return new Promise((resolve, reject) => { const listener = /* @__PURE__ */ __name((message) => { try { if (message.readUInt16BE(0) !== 2) return; const packet = parseLocalPacket(message); this.socket.off("message", listener); resolve(packet); } catch { } }, "listener"); this.socket.on("message", listener); this.socket.once( "close", () => reject(new Error("Cannot perform IP discovery - socket closed")) ); const discoveryBuffer = import_node_buffer2.Buffer.alloc(74); discoveryBuffer.writeUInt16BE(1, 0); discoveryBuffer.writeUInt16BE(70, 2); discoveryBuffer.writeUInt32BE(ssrc, 4); this.send(discoveryBuffer); }); } }; __name(_VoiceUDPSocket, "VoiceUDPSocket"); var VoiceUDPSocket = _VoiceUDPSocket; // src/networking/VoiceWebSocket.ts var import_node_events2 = require("events"); var import_v4 = require("discord-api-types/voice/v4"); var import_ws = require("ws"); var _VoiceWebSocket = class _VoiceWebSocket extends import_node_events2.EventEmitter { /** * Creates a new VoiceWebSocket. * * @param address - The address to connect to */ constructor(address, debug) { super(); /** * The current heartbeat interval, if any. */ __publicField(this, "heartbeatInterval"); /** * The time (milliseconds since UNIX epoch) that the last heartbeat acknowledgement packet was received. * This is set to 0 if an acknowledgement packet hasn't been received yet. */ __publicField(this, "lastHeartbeatAck"); /** * The time (milliseconds since UNIX epoch) that the last heartbeat was sent. This is set to 0 if a heartbeat * hasn't been sent yet. */ __publicField(this, "lastHeartbeatSend"); /** * The number of consecutively missed heartbeats. */ __publicField(this, "missedHeartbeats", 0); /** * The last recorded ping. */ __publicField(this, "ping"); /** * The debug logger function, if debugging is enabled. */ __publicField(this, "debug"); /** * The underlying WebSocket of this wrapper. */ __publicField(this, "ws"); this.ws = new import_ws.WebSocket(address); this.ws.onmessage = (err) => this.onMessage(err); this.ws.onopen = (err) => this.emit("open", err); this.ws.onerror = (err) => this.emit("error", err instanceof Error ? err : err.error); this.ws.onclose = (err) => this.emit("close", err); this.lastHeartbeatAck = 0; this.lastHeartbeatSend = 0; this.debug = debug ? (message) => this.emit("debug", message) : null; } /** * Destroys the VoiceWebSocket. The heartbeat interval is cleared, and the connection is closed. */ destroy() { try { this.debug?.("destroyed"); this.setHeartbeatInterval(-1); this.ws.close(1e3); } catch (error) { const err = error; this.emit("error", err); } } /** * Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them * as packets. * * @param event - The message event */ onMessage(event) { if (typeof event.data !== "string") return; this.debug?.(`<< ${event.data}`); let packet; try { packet = JSON.parse(event.data); } catch (error) { const err = error; this.emit("error", err); return; } if (packet.op === import_v4.VoiceOpcodes.HeartbeatAck) { this.lastHeartbeatAck = Date.now(); this.missedHeartbeats = 0; this.ping = this.lastHeartbeatAck - this.lastHeartbeatSend; } this.emit("packet", packet); } /** * Sends a JSON-stringifiable packet over the WebSocket. * * @param packet - The packet to send */ sendPacket(packet) { try { const stringified = JSON.stringify(packet); this.debug?.(`>> ${stringified}`); this.ws.send(stringified); } catch (error) { const err = error; this.emit("error", err); } } /** * Sends a heartbeat over the WebSocket. */ sendHeartbeat() { this.lastHeartbeatSend = Date.now(); this.missedHeartbeats++; const nonce2 = this.lastHeartbeatSend; this.sendPacket({ op: import_v4.VoiceOpcodes.Heartbeat, // eslint-disable-next-line id-length d: nonce2 }); } /** * Sets/clears an interval to send heartbeats over the WebSocket. * * @param ms - The interval in milliseconds. If negative, the interval will be unset */ setHeartbeatInterval(ms) { if (this.heartbeatInterval !== void 0) clearInterval(this.heartbeatInterval); if (ms > 0) { this.heartbeatInterval = setInterval(() => { if (this.lastHeartbeatSend !== 0 && this.missedHeartbeats >= 3) { this.ws.close(); this.setHeartbeatInterval(-1); } this.sendHeartbeat(); }, ms); } } }; __name(_VoiceWebSocket, "VoiceWebSocket"); var VoiceWebSocket = _VoiceWebSocket; // src/networking/Networking.ts var CHANNELS = 2; var TIMESTAMP_INC = 48e3 / 100 * CHANNELS; var MAX_NONCE_SIZE = 2 ** 32 - 1; var SUPPORTED_ENCRYPTION_MODES = ["aead_xchacha20_poly1305_rtpsize"]; if (import_node_crypto.default.getCiphers().includes("aes-256-gcm")) { SUPPORTED_ENCRYPTION_MODES.unshift("aead_aes256_gcm_rtpsize"); } var nonce = import_node_buffer3.Buffer.alloc(24); function stringifyState(state) { return JSON.stringify({ ...state, ws: Reflect.has(state, "ws"), udp: Reflect.has(state, "udp") }); } __name(stringifyState, "stringifyState"); function chooseEncryptionMode(options) { const option = options.find( (option2) => SUPPORTED_ENCRYPTION_MODES.includes(option2) ); if (!option) { throw new Error( `No compatible encryption modes. Available include: ${options.join( ", " )}` ); } return option; } __name(chooseEncryptionMode, "chooseEncryptionMode"); function randomNBit(numberOfBits) { return Math.floor(Math.random() * 2 ** numberOfBits); } __name(randomNBit, "randomNBit"); var _Networking = class _Networking extends import_node_events3.EventEmitter { /** * Creates a new Networking instance. */ constructor(options, debug) { super(); __publicField(this, "_state"); /** * The debug logger function, if debugging is enabled. */ __publicField(this, "debug"); this.onWsOpen = this.onWsOpen.bind(this); this.onChildError = this.onChildError.bind(this); this.onWsPacket = this.onWsPacket.bind(this); this.onWsClose = this.onWsClose.bind(this); this.onWsDebug = this.onWsDebug.bind(this); this.onUdpDebug = this.onUdpDebug.bind(this); this.onUdpClose = this.onUdpClose.bind(this); this.debug = debug ? (message) => this.emit("debug", message) : null; this._state = { code: 0 /* OpeningWs */, ws: this.createWebSocket(options.endpoint), connectionOptions: options }; } /** * Destroys the Networking instance, transitioning it into the Closed state. */ destroy() { this.state = { code: 6 /* Closed */ }; } /** * The current state of the networking instance. */ get state() { return this._state; } /** * Sets a new state for the networking instance, performing clean-up operations where necessary. */ set state(newState) { const oldWs = Reflect.get(this._state, "ws"); const newWs = Reflect.get(newState, "ws"); if (oldWs && oldWs !== newWs) { oldWs.off("debug", this.onWsDebug); oldWs.on("error", noop); oldWs.off("error", this.onChildError); oldWs.off("open", this.onWsOpen); oldWs.off("packet", this.onWsPacket); oldWs.off("close", this.onWsClose); oldWs.destroy(); } const oldUdp = Reflect.get(this._state, "udp"); const newUdp = Reflect.get(newState, "udp"); if (oldUdp && oldUdp !== newUdp) { oldUdp.on("error", noop); oldUdp.off("error", this.onChildError); oldUdp.off("close", this.onUdpClose); oldUdp.off("debug", this.onUdpDebug); oldUdp.destroy(); } const oldState = this._state; this._state = newState; this.emit("stateChange", oldState, newState); this.debug?.( `state change: from ${stringifyState(oldState)} to ${stringifyState( newState )}` ); } /** * Creates a new WebSocket to a Discord Voice gateway. * * @param endpoint - The endpoint to connect to */ createWebSocket(endpoint) { const ws = new VoiceWebSocket(`wss://${endpoint}?v=4`, Boolean(this.debug)); ws.on("error", this.onChildError); ws.once("open", this.onWsOpen); ws.on("packet", this.onWsPacket); ws.once("close", this.onWsClose); ws.on("debug", this.onWsDebug); return ws; } /** * Propagates errors from the children VoiceWebSocket and VoiceUDPSocket. * * @param error - The error that was emitted by a child */ onChildError(error) { this.emit("error", error); } /** * Called when the WebSocket opens. Depending on the state that the instance is in, * it will either identify with a new session, or it will attempt to resume an existing session. */ onWsOpen() { if (this.state.code === 0 /* OpeningWs */) { const packet = { op: import_v42.VoiceOpcodes.Identify, d: { server_id: this.state.connectionOptions.serverId, user_id: this.state.connectionOptions.userId, session_id: this.state.connectionOptions.sessionId, token: this.state.connectionOptions.token } }; this.state.ws.sendPacket(packet); this.state = { ...this.state, code: 1 /* Identifying */ }; } else if (this.state.code === 5 /* Resuming */) { const packet = { op: import_v42.VoiceOpcodes.Resume, d: { server_id: this.state.connectionOptions.serverId, session_id: this.state.connectionOptions.sessionId, token: this.state.connectionOptions.token } }; this.state.ws.sendPacket(packet); } } /** * Called when the WebSocket closes. Based on the reason for closing (given by the code parameter), * the instance will either attempt to resume, or enter the closed state and emit a 'close' event * with the close code, allowing the user to decide whether or not they would like to reconnect. * * @param code - The close code */ onWsClose({ code }) { const canResume = code === 4015 || code < 4e3; if (canResume && this.state.code === 4 /* Ready */) { this.state = { ...this.state, code: 5 /* Resuming */, ws: this.createWebSocket(this.state.connectionOptions.endpoint) }; } else if (this.state.code !== 6 /* Closed */) { this.destroy(); this.emit("close", code); } } /** * Called when the UDP socket has closed itself if it has stopped receiving replies from Discord. */ onUdpClose() { if (this.state.code === 4 /* Ready */) { this.state = { ...this.state, code: 5 /* Resuming */, ws: this.createWebSocket(this.state.connectionOptions.endpoint) }; } } /** * Called when a packet is received on the connection's WebSocket. * * @param packet - The received packet */ onWsPacket(packet) { if (packet.op === import_v42.VoiceOpcodes.Hello && this.state.code !== 6 /* Closed */) { this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval); } else if (packet.op === import_v42.VoiceOpcodes.Ready && this.state.code === 1 /* Identifying */) { const { ip, port, ssrc, modes } = packet.d; const udp = new VoiceUDPSocket({ ip, port }); udp.on("error", this.onChildError); udp.on("debug", this.onUdpDebug); udp.once("close", this.onUdpClose); udp.performIPDiscovery(ssrc).then((localConfig) => { if (this.state.code !== 2 /* UdpHandshaking */) return; this.state.ws.sendPacket({ op: import_v42.VoiceOpcodes.SelectProtocol, d: { protocol: "udp", data: { address: localConfig.ip, port: localConfig.port, mode: chooseEncryptionMode(modes) } } }); this.state = { ...this.state, code: 3 /* SelectingProtocol */ }; }).catch((error) => this.emit("error", error)); this.state = { ...this.state, code: 2 /* UdpHandshaking */, udp, connectionData: { ssrc } }; } else if (packet.op === import_v42.VoiceOpcodes.SessionDescription && this.state.code === 3 /* SelectingProtocol */) { const { mode: encryptionMode, secret_key: secretKey } = packet.d; this.state = { ...this.state, code: 4 /* Ready */, connectionData: { ...this.state.connectionData, encryptionMode, secretKey: new Uint8Array(secretKey), sequence: randomNBit(16), timestamp: randomNBit(32), nonce: 0, nonceBuffer: encryptionMode === "aead_aes256_gcm_rtpsize" ? import_node_buffer3.Buffer.alloc(12) : import_node_buffer3.Buffer.alloc(24), speaking: false, packetsPlayed: 0 } }; } else if (packet.op === import_v42.VoiceOpcodes.Resumed && this.state.code === 5 /* Resuming */) { this.state = { ...this.state, code: 4 /* Ready */ }; this.state.connectionData.speaking = false; } } /** * Propagates debug messages from the child WebSocket. * * @param message - The emitted debug message */ onWsDebug(message) { this.debug?.(`[WS] ${message}`); } /** * Propagates debug messages from the child UDPSocket. * * @param message - The emitted debug message */ onUdpDebug(message) { this.debug?.(`[UDP] ${message}`); } /** * Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it. * It will be stored within the instance, and can be played by dispatchAudio() * * @remarks * Calling this method while there is already a prepared audio packet that has not yet been dispatched * will overwrite the existing audio packet. This should be avoided. * @param opusPacket - The Opus packet to encrypt * @returns The audio packet that was prepared */ prepareAudioPacket(opusPacket) { const state = this.state; if (state.code !== 4 /* Ready */) return; state.preparedPacket = this.createAudioPacket( opusPacket, state.connectionData ); return state.preparedPacket; } /** * Dispatches the audio packet previously prepared by prepareAudioPacket(opusPacket). The audio packet * is consumed and cannot be dispatched again. */ dispatchAudio() { const state = this.state; if (state.code !== 4 /* Ready */) return false; if (state.preparedPacket !== void 0) { this.playAudioPacket(state.preparedPacket); state.preparedPacket = void 0; return true; } return false; } /** * Plays an audio packet, updating timing metadata used for playback. * * @param audioPacket - The audio packet to play */ playAudioPacket(audioPacket) { const state = this.state; if (state.code !== 4 /* Ready */) return; const { connectionData } = state; connectionData.packetsPlayed++; connectionData.sequence++; connectionData.timestamp += TIMESTAMP_INC; if (connectionData.sequence >= 2 ** 16) connectionData.sequence = 0; if (connectionData.timestamp >= 2 ** 32) connectionData.timestamp = 0; this.setSpeaking(true); state.udp.send(audioPacket); } /** * Sends a packet to the voice gateway indicating that the client has start/stopped sending * audio. * * @param speaking - Whether or not the client should be shown as speaking */ setSpeaking(speaking) { const state = this.state; if (state.code !== 4 /* Ready */) return; if (state.connectionData.speaking === speaking) return; state.connectionData.speaking = speaking; state.ws.sendPacket({ op: import_v42.VoiceOpcodes.Speaking, d: { speaking: speaking ? 1 : 0, delay: 0, ssrc: state.connectionData.ssrc } }); } /** * Creates a new audio packet from an Opus packet. This involves encrypting the packet, * then prepending a header that includes metadata. * * @param opusPacket - The Opus packet to prepare * @param connectionData - The current connection data of the instance */ createAudioPacket(opusPacket, connectionData) { const packetBuffer = import_node_buffer3.Buffer.alloc(12); packetBuffer[0] = 128; packetBuffer[1] = 120; const { sequence, timestamp, ssrc } = connectionData; packetBuffer.writeUIntBE(sequence, 2, 2); packetBuffer.writeUIntBE(timestamp, 4, 4); packetBuffer.writeUIntBE(ssrc, 8, 4); packetBuffer.copy(nonce, 0, 0, 12); return import_node_buffer3.Buffer.concat( [ // @ts-ignore packetBuffer, ...this.encryptOpusPacket(opusPacket, connectionData, packetBuffer) ] ); } /** * Encrypts an Opus packet using the format agreed upon by the instance and Discord. * * @param opusPacket - The Opus packet to encrypt * @param connectionData - The current connection data of the instance */ encryptOpusPacket(opusPacket, connectionData, data) { const { secretKey, encryptionMode } = connectionData; connectionData.nonce++; if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0; connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0); const noncePadding = connectionData.nonceBuffer.subarray(0, 4); let encrypted; switch (encryptionMode) { case "aead_aes256_gcm_rtpsize": { const cipher = import_node_crypto.default.createCipheriv( "aes-256-gcm", secretKey, connectionData.nonceBuffer ); cipher.setAAD(data); encrypted = import_node_buffer3.Buffer.concat( [ cipher.update(opusPacket), cipher.final(), cipher.getAuthTag() ] ); return [encrypted, noncePadding]; } case "aead_xchacha20_poly1305_rtpsize": { encrypted = methods.crypto_aead_xchacha20poly1305_ietf_encrypt( opusPacket, data, connectionData.nonceBuffer, secretKey ); return [encrypted, noncePadding]; } default: { throw new RangeError( `Unsupported encryption method: ${encryptionMode}` ); } } } }; __name(_Networking, "Networking"); var Networking = _Networking; // src/VoiceConnection.ts var VoiceConnectionStatus = /* @__PURE__ */ ((VoiceConnectionStatus2) => { VoiceConnectionStatus2["Connecting"] = "connecting"; VoiceConnectionStatus2["Destroyed"] = "destroyed"; VoiceConnectionStatus2["Disconnected"] = "disconnected"; VoiceConnectionStatus2["Ready"] = "ready"; VoiceConnectionStatus2["Signalling"] = "signalling"; return VoiceConnectionStatus2; })(VoiceConnectionStatus || {}); var VoiceConnectionDisconnectReason = /* @__PURE__ */ ((VoiceConnectionDisconnectReason2) => { VoiceConnectionDisconnectReason2[VoiceConnectionDisconnectReason2["WebSocketClose"] = 0] = "WebSocketClose"; VoiceConnectionDisconnectReason2[VoiceConnectionDisconnectReason2["AdapterUnavailable"] = 1] = "AdapterUnavailable"; VoiceConnectionDisconnectReason2[VoiceConnectionDisconnectReason2["EndpointRemoved"] = 2] = "EndpointRemoved"; VoiceConnectionDisconnectReason2[VoiceConnectionDisconnectReason2["Manual"] = 3] = "Manual"; return VoiceConnectionDisconnectReason2; })(VoiceConnectionDisconnectReason || {}); var _VoiceConnection = class _VoiceConnection extends import_node_events4.EventEmitter { /** * Creates a new voice connection. * * @param joinConfig - The data required to establish the voice connection * @param options - The options used to create this voice connection */ constructor(joinConfig, options) { super(); /** * The number of consecutive rejoin attempts. Initially 0, and increments for each rejoin. * When a connection is successfully established, it resets to 0. */ __publicField(this, "rejoinAttempts"); /** * The state of the voice connection. */ __publicField(this, "_state"); /** * A configuration storing all the data needed to reconnect to a Guild's voice server. * * @internal */ __publicField(this, "joinConfig"); /** * The two packets needed to successfully establish a voice connection. They are received * from the main Discord gateway after signalling to change the voice state. */ __publicField(this, "packets"); /** * The debug logger function, if debugging is enabled. */ __publicField(this, "debug"); this.debug = options.debug ? (message) => this.emit("debug", message) : null; this.rejoinAttempts = 0; this.onNetworkingClose = this.onNetworkingClose.bind(this); this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this); this.onNetworkingError = this.onNetworkingError.bind(this); this.onNetworkingDebug = this.onNetworkingDebug.bind(this); const adapter = options.adapterCreator({ onVoiceServerUpdate: /* @__PURE__ */ __name((data) => this.addServerPacket(data), "onVoiceServerUpdate"), onVoiceStateUpdate: /* @__PURE__ */ __name((data) => this.addStatePacket(data), "onVoiceStateUpdate"), destroy: /* @__PURE__ */ __name(() => this.destroy(false), "destroy") }); this._state = { status: "signalling" /* Signalling */, adapter }; this.packets = { server: void 0, state: void 0 }; this.joinConfig = joinConfig; } /** * The current state of the voice connection. */ get state() { return this._state; } /** * Updates the state of the voice connection, performing clean-up operations where necessary. */ set state(newState) { const oldState = this._state; const oldNetworking = Reflect.get(oldState, "networking"); const newNetworking = Reflect.get(newState, "networking"); const oldSubscription = Reflect.get(oldState, "subscription"); const newSubscription = Reflect.get(newState, "subscription"); if (oldNetworking !== newNetworking) { if (oldNetworking) { oldNetworking.on("error", noop); oldNetworking.off("debug", this.onNetworkingDebug); oldNetworking.off("error", this.onNetworkingError); oldNetworking.off("close", this.onNetworkingClose); oldNetworking.off("stateChange", this.onNetworkingStateChange); oldNetworking.destroy(); } } if (newState.status === "ready" /* Ready */) { this.rejoinAttempts = 0; } if (oldState.status !== "destroyed" /* Destroyed */ && newState.status === "destroyed" /* Destroyed */) { oldState.adapter.destroy(); } this._state = newState; if (oldSubscription && oldSubscription !== newSubscription) { oldSubscription.unsubscribe(); } this.emit("stateChange", oldState, newState); if (oldState.status !== newState.status) { this.emit(newState.status, oldState, newState); } } /** * Registers a `VOICE_SERVER_UPDATE` packet to the voice connection. This will cause it to reconnect using the * new data provided in the packet. * * @param packet - The received `VOICE_SERVER_UPDATE` packet */ addServerPacket(packet) { this.packets.server = packet; if (packet.endpoint) { this.configureNetworking(); } else if (this.state.status !== "destroyed" /* Destroyed */) { this.state = { ...this.state, status: "disconnected" /* Disconnected */, reason: 2 /* EndpointRemoved */ }; } } /** * Registers a `VOICE_STATE_UPDATE` packet to the voice connection. Most importantly, it stores the id of the * channel that the client is connected to. * * @param packet - The received `VOICE_STATE_UPDATE` packet */ addStatePacket(packet) { this.packets.state = packet; if (packet.self_deaf !== void 0) this.joinConfig.selfDeaf = packet.self_deaf; if (packet.self_mute !== void 0) this.joinConfig.selfMute = packet.self_mute; if (packet.channel_id) this.joinConfig.channelId = packet.channel_id; } /** * Attempts to configure a networking instance for this voice connection using the received packets. * Both packets are required, and any existing networking instance will be destroyed. * * @remarks * This is called when the voice server of the connection changes, e.g. if the bot is moved into a * different channel in the same guild but has a different voice server. In this instance, the connection * needs to be re-established to the new voice server. * * The connection will transition to the Connecting state when this is called. */ configureNetworking() { const { server, state } = this.packets; if (!server || !state || this.state.status === "destroyed" /* Destroyed */ || !server.endpoint) return; const networking = new Networking( { endpoint: server.endpoint, serverId: server.guild_id, token: server.token, sessionId: state.session_id, userId: state.user_id }, Boolean(this.debug) ); networking.once("close", this.onNetworkingClose); networking.on("stateChange", this.onNetworkingStateChange); networking.on("error", this.onNetworkingError); networking.on("debug", this.onNetworkingDebug); this.state = { ...this.state, status: "connecting" /* Connecting */, networking }; } /** * Called when the networking instance for this connection closes. If the close code is 4014 (do not reconnect), * the voice connection will transition to the Disconnected state which will store the close code. You can * decide whether or not to reconnect when this occurs by listening for the state change and calling reconnect(). * * @remarks * If the close code was anything other than 4014, it is likely that the closing was not intended, and so the * VoiceConnection will signal to Discord that it would like to rejoin the channel. This automatically attempts * to re-establish the connection. This would be seen as a transition from the Ready state to the Signalling state. * @param code - The close code */ onNetworkingClose(code) { if (this.state.status === "destroyed" /* Destroyed */) return; if (code === 4014) { this.state = { ...this.state, status: "disconnected" /* Disconnected */, reason: 0 /* WebSocketClose */, closeCode: code }; } else { this.state = { ...this.state, status: "signalling" /* Signalling */ }; this.rejoinAttempts++; if (!this.state.adapter.sendPayload( createJoinVoiceChannelPayload(this.joinConfig) )) { this.state = { ...this.state, status: "disconnected" /* Disconnected */, reason: 1 /* AdapterUnavailable */ }; } } } /** * Called when the state of the networking instance changes. This is used to derive the state of the voice connection. * * @param oldState - The previous state * @param newState - The new state */ onNetworkingStateChange(oldState, newState) { if (oldState.code === newState.code) return; if (this.state.status !== "connecting" /* Connecting */ && this.state.status !== "ready" /* Ready */) return; if (newState.code === 4 /* Ready */) { this.state = { ...this.state, status: "ready" /* Ready */ }; } else if (newState.code !== 6 /* Closed */) { this.state = { ...this.state, status: "connecting" /* Connecting */ }; } } /** * Propagates errors from the underlying network instance. * * @param error - The error to propagate */ onNetworkingError(error) { this.emit("error", error); } /** * Propagates debug messages from the underlying network instance. * * @param message - The debug message to propagate */ onNetworkingDebug(message) { this.debug?.(`[NW] ${message}`); } /** * Prepares an audio packet for dispatch. * * @param buffer - The Opus packet to prepare */ prepareAudioPacket(buffer) { const state = this.state; if (state.status !== "ready" /* Ready */) return; return state.networking.prepareAudioPacket(buffer); } /** * Dispatches the previously prepared audio packet (if any) */ dispatchAudio() { const state = this.state; if (state.status !== "ready" /* Ready */) return; return state.networking.dispatchAudio(); } /** * Prepares an audio packet and dispatches it immediately. * * @param buffer - The Opus packet to play */ playOpusPacket(buffer) { const state = this.state; if (state.status !== "ready" /* Ready */) return; state.networking.prepareAudioPacket(buffer); return state.networking.dispatchAudio(); } /** * Destroys the VoiceConnection, preventing it from connecting to voice again. * This method should be called when you no longer require the VoiceConnection to * prevent memory leaks. * * @param adapterAvailable - Whether the adapter can be used */ destroy(adapterAvailable = true) { if (this.state.status === "destroyed" /* Destroyed */) { throw new Error( "Cannot destroy VoiceConnection - it has already been destroyed" ); } if (getVoiceConnection(this.joinConfig.guildId, this.joinConfig.group) === this) { untrackVoiceConnection(this); } if (adapterAvailable) { this.state.adapter.sendPayload( createJoinVoiceChannelPayload({ ...this.joinConfig, channelId: null }) ); } this.state = { status: "destroyed" /* Destroyed */ }; } /** * Disconnects the VoiceConnection, allowing the possibility of rejoining later on. * * @returns `true` if the connection was successfully disconnected */ disconnect() { if (this.state.status === "destroyed" /* Destroyed */ || this.state.status === "signalling" /* Signalling */) { return false; } this.joinConfig.channelId = null; if (!this.state.adapter.sendPayload( createJoinVoiceChannelPayload(this.joinConfig) )) { this.state = { adapter: this.state.adapter, subscription: this.state.subscription, status: "disconnected" /* Disconnected */, reason: 1 /* AdapterUnavailable */ }; return false; } this.state = { adapter: this.state.adapter, reason: 3 /* Manual */, status: "disconnected" /* Disconnected */ }; return true; } /** * Attempts to rejoin (better explanation soon:tm:) * * @remarks * Calling this method successfully will automatically increment the `rejoinAttempts` counter, * which you can use to inform whether or not you'd like to keep attempting to reconnect your * voice connection. * * A state transition from Disconnected to Signalling will be observed when this is called. */ rejoin(joinConfig) { if (this.state.status === "destroyed" /* Destroyed */) { return false; } const notReady = this.state.status !== "ready" /* Ready */; if (notReady) this.rejoinAttempts++; Object.assign(this.joinConfig, joinConfig); if (this.state.adapter.sendPayload( createJoinVoiceChannelPayload(this.joinConfig) )) { if (notReady) { this.state = { ...this.state, status: "signalling" /* Signalling */ }; } return true; } this.state = { adapter: this.state.adapter, subscription: this.state.subscription, status: "disconnected" /* Disconnected */, reason: 1 /* AdapterUnavailable */ }; return false; } /** * Updates the speaking status of the voice connection. This is used when audio players are done playing audio, * and need to signal that the connection is no longer playing audio. * * @param enabled - Whether or not to show as speaking */ setSpeaking(enabled) { if (this.state.status !== "ready" /* Ready */) return false; return this.state.networking.setSpeaking(enabled); } /** * Subscribes to an audio player, allowing the player to play audio on this voice connection. * * @param player - The audio player to subscribe to * @returns The created subscription */ subscribe(player) { if (this.state.status === "destroyed" /* Destroyed */) return; const subscription = player["subscribe"](this); this.state = { ...this.state, subscription }; return subscription; } /** * The latest ping (in milliseconds) for the WebSocket connection and audio playback for this voice * connection, if this data is available. * * @remarks * For this data to be available, the VoiceConnection must be in the Ready state, and its underlying * WebSocket connection and UDP socket must have had at least one ping-pong exchange. */ get ping() { if (this.state.status === "ready" /* Ready */ && this.state.networking.state.code === 4 /* Ready */) { return { ws: this.state.networking.state.ws.ping, udp: this.state.networking.state.udp.ping }; } return { ws: void 0, udp: void 0 }; } /** * Called when a subscription of this voice connection to an audio player is removed. * * @param subscription - The removed subscription */ onSubscriptionRemoved(subscription) { if (this.state.status !== "destroyed" /* Destroyed */ && this.state.subscription === subscription) { this.state = { ...this.state, subscription: void 0 }; } } }; __name(_VoiceConnection, "VoiceConnection"); var VoiceConnection = _VoiceConnection; function createVoiceConnection(joinConfig, options) { const payload = createJoinVoiceChannelPayload(joinConfig); const existing = getVoiceConnection(joinConfig.guildId, joinConfig.group); if (existing && existing.state.status !== "destroyed" /* Destroyed */) { if (existing.state.status === "disconnected" /* Disconnected */) { existing.rejoin({ channelId: joinConfig.channelId, selfDeaf: joinConfig.selfDeaf, selfMute: joinConfig.selfMute }); } else if (!existing.state.adapter.sendPayload(payload)) { existing.state = { ...existing.state, status: "disconnected" /* Disconnected */, reason: 1 /* AdapterUnavailable */ }; } return existing; } const voiceConnection = new VoiceConnection(joinConfig, options); trackVoiceConnection(voiceConnection); if (voiceConnection.state.status !== "destroyed" /* Destroyed */ && !voiceConnection.state.adapter.sendPayload(payload)) { voiceConnection.state = { ...voiceConnection.state, status: "disconnected" /* Disconnected */, reason: 1 /* AdapterUnavailable */ }; } return voiceConnection; } __name(createVoiceConnection, "createVoiceConnection"); // src/joinVoiceChannel.ts function joinVoiceChannel(options) { const joinConfig = { selfDeaf: true, selfMute: false, group: "default", ...options }; return createVoiceConnection(joinConfig, { adapterCreator: options.adapterCreator, debug: options.debug }); } __name(joinVoiceChannel, "joinVoiceChannel"); // src/audio/AudioPlayer.ts var import_node_buffer4 = require("buffer"); var import_node_events5 = require("events"); // src/audio/AudioPlayerError.ts var _AudioPlayerError = class _AudioPlayerError extends Error { constructor(error, resource) { super(error.message); /** * The resource associated with the audio player at the time the error was thrown. */ __publicField(this, "resource"); this.resource = resource; this.name = error.name; this.stack = error.stack; } }; __name(_AudioPlayerError, "AudioPlayerError"); var AudioPlayerError = _AudioPlayerError; // src/audio/PlayerSubscription.ts var _PlayerSubscription = class _PlayerSubscription { constructor(connection, player) { /** * The voice connection of this subscription. */ __publicField(this, "connection"); /** * The audio player of this subscription. */ __publicField(this, "player"); this.connection = connection; this.player = player; } /** * Unsubscribes the connection from the audio player, meaning that the * audio player cannot stream audio to it until a new subscription is made. */ unsubscribe() { this.connection["onSubscriptionRemoved"](this); this.player["unsubscribe"](this); } }; __name(_PlayerSubscription, "PlayerSubscription"); var PlayerSubscription = _PlayerSubscription; // src/audio/AudioPlayer.ts var SILENCE_FRAME = import_node_buffer4.Buffer.from([248, 255, 254]); var NoSubscriberBehavior = /* @__PURE__ */ ((NoSubscriberBehavior2) => { NoSubscriberBehavior2["Pause"] = "pause"; NoSubscriberBehavior2["Play"] = "play"; NoSubscrib