discord-voip
Version:
Discord VoIP library used by discord-player
1,474 lines (1,459 loc) • 280 kB
JavaScript
"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