UNPKG

discord.js-selfbot-v13

Version:

A unofficial discord.js fork for creating selfbots [Based on discord.js v13]

1,234 lines (1,116 loc) 34.5 kB
'use strict'; const EventEmitter = require('events'); const { getCiphers } = require('node:crypto'); const { setTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection'); const VoiceUDP = require('./networking/VoiceUDPClient'); const VoiceWebSocket = require('./networking/VoiceWebSocket'); const MediaPlayer = require('./player/MediaPlayer'); const VoiceReceiver = require('./receiver/Receiver'); const { parseStreamKey } = require('./util/Function'); const PlayInterface = require('./util/PlayInterface'); const Silence = require('./util/Silence'); const { Error } = require('../../errors'); const { Opcodes, VoiceOpcodes, VoiceStatus, Events } = require('../../util/Constants'); const Speaking = require('../../util/Speaking'); const Util = require('../../util/Util'); // Workaround for Discord now requiring silence to be sent before being able to receive audio class SingleSilence extends Silence { _read() { super._read(); this.push(null); } } const SUPPORTED_MODES = ['aead_xchacha20_poly1305_rtpsize']; // Just in case there's some system that doesn't come with aes-256-gcm, conditionally add it as supported if (getCiphers().includes('aes-256-gcm')) { SUPPORTED_MODES.unshift('aead_aes256_gcm_rtpsize'); } const SUPPORTED_CODECS = ['VP8', 'H264']; /** * Represents a connection to a guild's voice server. * ```js * // Obtained using: * client.voice.joinChannel(channel) * .then(connection => { * * }); * ``` * @extends {EventEmitter} * @implements {PlayInterface} */ class VoiceConnection extends EventEmitter { constructor(voiceManager, channel) { super(); /** * The voice manager that instantiated this connection * @type {ClientVoiceManager} */ this.voiceManager = voiceManager; /** * The voice channel this connection is currently serving * @type {VoiceChannel} */ this.channel = channel; /** * The current status of the voice connection * @type {VoiceStatus} */ this.status = VoiceStatus.AUTHENTICATING; /** * Our current speaking state * @type {Readonly<Speaking>} */ this.speaking = new Speaking().freeze(); /** * Our current video state * @type {boolean} */ this.videoStatus = false; /** * The authentication data needed to connect to the voice server * @type {Object} * @private */ this.authentication = {}; /** * The audio player for this voice connection * @type {MediaPlayer} */ this.player = new MediaPlayer(this, this.constructor.name === 'StreamConnection'); this.player.on('debug', m => { /** * Debug info from the connection. * @event VoiceConnection#debug * @param {string} message The debug message */ this.emit('debug', `audio player - ${m}`); }); this.player.on('error', e => { /** * Warning info from the connection. * @event VoiceConnection#warn * @param {string|Error} warning The warning */ this.emit('warn', e); }); this.once('closing', () => this.player.destroy()); /** * Map SSRC values to user IDs * @type {Map<number, { userId: Snowflake, speaking: boolean, hasVideo: boolean }>} * @private */ this.ssrcMap = new Map(); /** * Tracks which users are talking * @type {Map<Snowflake, Readonly<Speaking>>} * @private */ this._speaking = new Map(); /** * Object that wraps contains the `ws` and `udp` sockets of this voice connection * @type {Object} * @private */ this.sockets = {}; /** * The voice receiver of this connection * @type {VoiceReceiver} */ this.receiver = new VoiceReceiver(this); /** * Video codec * * `VP8` * * `VP9` (Not supported for encoding) * * `H264` * * `H265` (Not supported for encoding, worked for decoding) * * `AV1` (Not supported for encoding) * @typedef {string} VideoCodec */ /** * Video codec (encoded) of this connection * @type {VideoCodec} */ this.videoCodec = 'H264'; /** * Create a stream connection ? * @type {?StreamConnection} */ this.streamConnection = null; /** * All stream watch connection * @type {Collection<Snowflake, StreamConnectionReadonly>} */ this.streamWatchConnection = new Collection(); } /** * The client that instantiated this connection * @type {Client} * @readonly */ get client() { return this.voiceManager.client; } /** * The current audio dispatcher (if any) * @type {?AudioDispatcher} * @readonly */ get dispatcher() { return this.player.dispatcher; } /** * The current video dispatcher (if any) * @type {?VideoDispatcher} * @readonly */ get videoDispatcher() { return this.player.videoDispatcher; } /** * Sets whether the voice connection should display as "speaking", "soundshare" or "none". * @param {BitFieldResolvable} value The new speaking state */ setSpeaking(value) { if (this.speaking.equals(value)) return; if (this.status !== VoiceStatus.CONNECTED) return; this.speaking = new Speaking(value).freeze(); this.sockets.ws .sendPacket({ op: VoiceOpcodes.SPEAKING, d: { speaking: this.speaking.bitfield, delay: 0, ssrc: this.authentication.ssrc, }, }) .catch(e => { this.emit('debug', e); }); } /** * Set video codec before select protocol * @param {VideoCodec} value Codec * @returns {VoiceConnection} */ setVideoCodec(value) { if (!SUPPORTED_CODECS.includes(value)) throw new Error('INVALID_VIDEO_CODEC', SUPPORTED_CODECS); this.videoCodec = value; return this; } /** * Sets video status * @param {boolean} value Video on or off */ setVideoStatus(value) { if (this.status !== VoiceStatus.CONNECTED) return; if (value === this.videoStatus) return; this.videoStatus = value; this.sockets.ws .sendPacket({ op: VoiceOpcodes.SOURCES, d: { audio_ssrc: this.authentication.ssrc, video_ssrc: value ? this.authentication.ssrc + 1 : 0, rtx_ssrc: value ? this.authentication.ssrc + 2 : 0, streams: [ { type: 'video', rid: '100', ssrc: value ? this.authentication.ssrc + 1 : 0, active: true, quality: 100, rtx_ssrc: value ? this.authentication.ssrc + 2 : 0, max_bitrate: 8000000, max_framerate: 60, max_resolution: { type: 'source', width: 0, height: 0, }, }, ], }, }) .catch(e => { this.emit('debug', e); }); } /** * The voice state of this connection * @type {?VoiceState} */ get voice() { return this.client.user.voice; } /** * Sends a request to the main gateway to join a voice channel. * @param {Object} [options] The options to provide * @returns {Promise<Shard>} * @private */ sendVoiceStateUpdate(options = {}) { options = Util.mergeDefault( { guild_id: this.channel.guild?.id || null, channel_id: this.channel.id, self_mute: this.voice ? this.voice.selfMute : false, self_deaf: this.voice ? this.voice.selfDeaf : false, self_video: this.voice ? this.voice.selfVideo : false, flags: 2, }, options, ); this.emit('debug', `Sending voice state update: ${JSON.stringify(options)}`); return this.channel.client.ws.broadcast({ op: Opcodes.VOICE_STATE_UPDATE, d: options, }); } /** * Set the token and endpoint required to connect to the voice servers. * @param {string} token The voice token * @param {string} endpoint The voice endpoint * @returns {void} * @private */ setTokenAndEndpoint(token, endpoint) { this.emit('debug', `Token "${token}" and endpoint "${endpoint}"`); if (!endpoint) { // Signifies awaiting endpoint stage return; } if (!token) { this.authenticateFailed('VOICE_TOKEN_ABSENT'); return; } endpoint = endpoint.match(/([^:]*)/)[0]; this.emit('debug', `Endpoint resolved as ${endpoint}`); if (!endpoint) { this.authenticateFailed('VOICE_INVALID_ENDPOINT'); return; } if (this.status === VoiceStatus.AUTHENTICATING) { this.authentication.token = token; this.authentication.endpoint = endpoint; this.checkAuthenticated(); } else if (token !== this.authentication.token || endpoint !== this.authentication.endpoint) { this.reconnect(token, endpoint); } } /** * Sets the Session ID for the connection. * @param {string} sessionId The voice session ID * @private */ setSessionId(sessionId) { this.emit('debug', `Setting sessionId ${sessionId} (stored as "${this.authentication.sessionId}")`); if (!sessionId) { this.authenticateFailed('VOICE_SESSION_ABSENT'); return; } if (this.status === VoiceStatus.AUTHENTICATING) { this.authentication.sessionId = sessionId; this.checkAuthenticated(); } else if (sessionId !== this.authentication.sessionId) { this.authentication.sessionId = sessionId; /** * Emitted when a new session ID is received. * @event VoiceConnection#newSession * @private */ this.emit('newSession', sessionId); } } /** * Checks whether the voice connection is authenticated. * @private */ checkAuthenticated() { const { token, endpoint, sessionId } = this.authentication; this.emit('debug', `Authenticated with sessionId ${sessionId}`); if (token && endpoint && sessionId) { this.status = VoiceStatus.CONNECTING; /** * Emitted when we successfully initiate a voice connection. * @event VoiceConnection#authenticated */ this.emit('authenticated'); this.connect(); } } /** * Invoked when we fail to initiate a voice connection. * @param {string} reason The reason for failure * @private */ authenticateFailed(reason) { clearTimeout(this.connectTimeout); this.emit('debug', `Authenticate failed - ${reason}`); if (this.status === VoiceStatus.AUTHENTICATING) { /** * Emitted when we fail to initiate a voice connection. * @event VoiceConnection#failed * @param {Error} error The encountered error */ this.emit('failed', new Error(reason)); } else { /** * Emitted whenever the connection encounters an error. * @event VoiceConnection#error * @param {Error} error The encountered error */ this.emit('error', new Error(reason)); } this.status = VoiceStatus.DISCONNECTED; } /** * Move to a different voice channel in the same guild. * @param {VoiceChannel} channel The channel to move to * @private */ updateChannel(channel) { this.channel = channel; this.sendVoiceStateUpdate(); } /** * Attempts to authenticate to the voice server. * @param {Object} options Join config * @private */ authenticate(options = {}) { this.sendVoiceStateUpdate(options); this.connectTimeout = setTimeout(() => this.authenticateFailed('VOICE_CONNECTION_TIMEOUT'), 15_000).unref(); } /** * Attempts to reconnect to the voice server (typically after a region change). * @param {string} token The voice token * @param {string} endpoint The voice endpoint * @private */ reconnect(token, endpoint) { this.authentication.token = token; this.authentication.endpoint = endpoint; this.speaking = new Speaking().freeze(); this.status = VoiceStatus.RECONNECTING; this.emit('debug', `Reconnecting to ${endpoint}`); /** * Emitted when the voice connection is reconnecting (typically after a region change). * @event VoiceConnection#reconnecting */ this.emit('reconnecting'); this.connect(); } /** * Disconnects the voice connection, causing a disconnect and closing event to be emitted. */ disconnect() { this.emit('closing'); this.emit('debug', 'disconnect() triggered'); clearTimeout(this.connectTimeout); const conn = this.voiceManager.connection; if (conn === this) this.voiceManager.connection = null; this.sendVoiceStateUpdate({ channel_id: null, }); this._disconnect(); } /** * Internally disconnects (doesn't send disconnect packet). * @private */ _disconnect() { this.cleanup(); this.status = VoiceStatus.DISCONNECTED; /** * Emitted when the voice connection disconnects. * @event VoiceConnection#disconnect */ this.emit('disconnect'); } /** * Cleans up after disconnect. * @private */ cleanup() { this.player.destroy(); this.speaking = new Speaking().freeze(); const { ws, udp } = this.sockets; this.emit('debug', 'Connection clean up'); if (ws) { ws.removeAllListeners('error'); ws.removeAllListeners('ready'); ws.removeAllListeners('sessionDescription'); ws.removeAllListeners('speaking'); ws.shutdown(); } if (udp) udp.removeAllListeners('error'); this.sockets.ws = null; this.sockets.udp = null; } /** * Connect the voice connection. * @private */ connect() { this.emit('debug', `Connect triggered`); if (this.status !== VoiceStatus.RECONNECTING) { if (this.sockets.ws) throw new Error('WS_CONNECTION_EXISTS'); if (this.sockets.udp) throw new Error('UDP_CONNECTION_EXISTS'); } if (this.sockets.ws) this.sockets.ws.shutdown(); if (this.sockets.udp) this.sockets.udp.shutdown(); this.sockets.ws = new VoiceWebSocket(this); this.sockets.udp = new VoiceUDP(this); const { ws, udp } = this.sockets; ws.on('debug', msg => this.emit('debug', msg)); udp.on('debug', msg => this.emit('debug', msg)); ws.on('error', err => this.emit('error', err)); udp.on('error', err => this.emit('error', err)); ws.on('ready', this.onReady.bind(this)); ws.on('sessionDescription', this.onSessionDescription.bind(this)); ws.on('startSpeaking', this.onStartSpeaking.bind(this)); ws.on('startStreaming', this.onStartStreaming.bind(this)); this.sockets.ws.connect(); } /** * Invoked when the voice websocket is ready. * @param {Object} data The received data * @private */ onReady(data) { Object.assign(this.authentication, data); for (let mode of data.modes) { if (SUPPORTED_MODES.includes(mode)) { this.authentication.mode = mode; this.emit('debug', `Selecting the ${mode} mode`); break; } } this.sockets.udp.createUDPSocket(data.ip); } /** * Invoked when a session description is received. * @param {Object} data The received data * @private */ onSessionDescription(data) { Object.assign(this.authentication, data); this.status = VoiceStatus.CONNECTED; const ready = () => { clearTimeout(this.connectTimeout); this.emit('debug', `Ready with authentication details: ${JSON.stringify(this.authentication)}`); /** * Emitted once the connection is ready, when a promise to join a voice channel resolves, * the connection will already be ready. * @event VoiceConnection#ready */ this.emit('ready'); }; if (this.dispatcher || this.videoDispatcher) { ready(); } else { // This serves to provide support for voice receive, sending audio is required to receive it. const dispatcher = this.playAudio(new SingleSilence(), { type: 'opus', volume: false }); dispatcher.once('finish', ready); } } onStartSpeaking({ user_id, ssrc, speaking }) { this.ssrcMap.set(+ssrc, { ...(this.ssrcMap.get(+ssrc) || {}), userId: user_id, speaking: speaking, }); } onStartStreaming({ video_ssrc, user_id, audio_ssrc }) { this.ssrcMap.set(+audio_ssrc, { ...(this.ssrcMap.get(+audio_ssrc) || {}), userId: user_id, hasVideo: Boolean(video_ssrc), // Maybe ? }); /** { video_ssrc: 0, user_id: 'uid', streams: [ { ssrc: 27734, rtx_ssrc: 27735, rid: '100', quality: 100, max_resolution: { width: 0, type: 'source', height: 0 },, max_framerate: 60, active: false } ], audio_ssrc: 27733 } */ } /** * Invoked when a speaking event is received. * @param {Object} data The received data * @private */ onSpeaking({ user_id, speaking }) { speaking = new Speaking(speaking).freeze(); const guild = this.channel.guild; const user = this.client.users.cache.get(user_id); const old = this._speaking.get(user_id) || new Speaking(0).freeze(); this._speaking.set(user_id, speaking); /** * Emitted whenever a user changes speaking state. * @event VoiceConnection#speaking * @param {User} user The user that has changed speaking state * @param {Readonly<Speaking>} speaking The speaking state of the user */ if (this.status === VoiceStatus.CONNECTED) { this.emit('speaking', user, speaking); if (!speaking.has(Speaking.FLAGS.SPEAKING)) { this.receiver.packets._stoppedSpeaking(user_id); } } if (guild && user && !speaking.equals(old)) { const member = guild.members.cache.get(user); if (member) { /** * Emitted once a guild member changes speaking state. * @event Client#guildMemberSpeaking * @param {GuildMember} member The member that started/stopped speaking * @param {Readonly<Speaking>} speaking The speaking state of the member */ this.client.emit(Events.GUILD_MEMBER_SPEAKING, member, speaking); } } } playAudio() {} // eslint-disable-line no-empty-function playVideo() {} // eslint-disable-line no-empty-function /** * Create new connection to screenshare stream * @returns {Promise<StreamConnection>} */ createStreamConnection() { // eslint-disable-next-line consistent-return return new Promise((resolve, reject) => { if (this.streamConnection) { return resolve(this.streamConnection); } else { const connection = (this.streamConnection = new StreamConnection(this.voiceManager, this.channel, this)); connection.setVideoCodec(this.videoCodec); // Sync :? // Setup event... if (!this.eventHook) { this.eventHook = true; // Dont listen this event two times :/ this.channel.client.on('raw', packet => { if (typeof packet !== 'object' || !packet.t || !packet.d || !packet.d?.stream_key) { return; } const { t: event, d: data } = packet; const StreamKey = parseStreamKey(data.stream_key); if ( StreamKey.userId === this.channel.client.user.id && this.channel.id == StreamKey.channelId && this.streamConnection ) { // Current user stream switch (event) { case 'STREAM_CREATE': { this.streamConnection.setSessionId(this.authentication.sessionId); this.streamConnection.serverId = data.rtc_server_id; break; } case 'STREAM_SERVER_UPDATE': { this.streamConnection.setTokenAndEndpoint(data.token, data.endpoint); break; } case 'STREAM_DELETE': { this.streamConnection.disconnect(); break; } case 'STREAM_UPDATE': { this.streamConnection.update(data); break; } } } if (this.streamWatchConnection.has(StreamKey.userId) && this.channel.id == StreamKey.channelId) { const streamConnection = this.streamWatchConnection.get(StreamKey.userId); // Watch user stream switch (event) { case 'STREAM_CREATE': { streamConnection.setSessionId(this.authentication.sessionId); streamConnection.serverId = data.rtc_server_id; break; } case 'STREAM_SERVER_UPDATE': { streamConnection.setTokenAndEndpoint(data.token, data.endpoint); break; } case 'STREAM_DELETE': { streamConnection.disconnect(); streamConnection.receiver.packets.destroyAllStream(); break; } case 'STREAM_UPDATE': { streamConnection.update(data); break; } } } }); } connection.sendSignalScreenshare(); connection.sendScreenshareState(true); connection.on('debug', msg => this.channel.client.emit( 'debug', `[VOICE STREAM (${this.channel.guild?.id || this.channel.id}:${connection.status})]: ${msg}`, ), ); connection.once('failed', reason => { this.streamConnection = null; reject(reason); }); connection.on('error', reject); connection.once('authenticated', () => { connection.once('ready', () => { resolve(connection); connection.removeListener('error', reject); }); connection.once('disconnect', () => { this.streamConnection = null; }); }); } }); } /** * Watch user stream * @param {UserResolvable} user Discord user * @returns {Promise<StreamConnectionReadonly>} */ async joinStreamConnection(user) { const userId = this.client.users.resolveId(user); // Check if user is streaming if (!userId) { throw new Error('VOICE_USER_MISSING'); } const voiceState = this.channel.guild?.voiceStates.cache.get(userId) || this.client.voiceStates.cache.get(userId); if (!voiceState || !voiceState.streaming) { throw new Error('VOICE_USER_NOT_STREAMING'); } // eslint-disable-next-line consistent-return return new Promise((resolve, reject) => { if (this.streamWatchConnection.has(userId)) { return resolve(this.streamWatchConnection.get(userId)); } else { const connection = new StreamConnectionReadonly(this.voiceManager, this.channel, this, userId); this.streamWatchConnection.set(userId, connection); connection.setVideoCodec(this.videoCodec); // Setup event... if (!this.eventHook) { this.eventHook = true; // Dont listen this event two times :/ this.channel.client.on('raw', packet => { if (typeof packet !== 'object' || !packet.t || !packet.d || !packet.d?.stream_key) { return; } const { t: event, d: data } = packet; const StreamKey = parseStreamKey(data.stream_key); if ( StreamKey.userId === this.channel.client.user.id && this.channel.id == StreamKey.channelId && this.streamConnection ) { // Current user stream switch (event) { case 'STREAM_CREATE': { this.streamConnection.setSessionId(this.authentication.sessionId); this.streamConnection.serverId = data.rtc_server_id; break; } case 'STREAM_SERVER_UPDATE': { this.streamConnection.setTokenAndEndpoint(data.token, data.endpoint); break; } case 'STREAM_DELETE': { this.streamConnection.disconnect(); break; } case 'STREAM_UPDATE': { this.streamConnection.update(data); break; } } } if (this.streamWatchConnection.has(StreamKey.userId) && this.channel.id == StreamKey.channelId) { const streamConnection = this.streamWatchConnection.get(StreamKey.userId); // Watch user stream switch (event) { case 'STREAM_CREATE': { streamConnection.setSessionId(this.authentication.sessionId); streamConnection.serverId = data.rtc_server_id; break; } case 'STREAM_SERVER_UPDATE': { streamConnection.setTokenAndEndpoint(data.token, data.endpoint); break; } case 'STREAM_DELETE': { streamConnection.disconnect(); streamConnection.receiver.packets.destroyAllStream(); break; } case 'STREAM_UPDATE': { streamConnection.update(data); break; } } } }); } connection.sendSignalScreenshare(); connection.on('debug', msg => this.channel.client.emit( 'debug', `[VOICE STREAM WATCH (${userId}>${this.channel.guild?.id || this.channel.id}:${ connection.status })]: ${msg}`, ), ); connection.once('failed', reason => { this.streamWatchConnection.delete(userId); reject(reason); }); connection.on('error', reject); connection.once('authenticated', () => { connection.once('ready', () => { resolve(connection); connection.removeListener('error', reject); }); connection.once('disconnect', () => { this.streamWatchConnection.delete(userId); }); }); } }); } /** * @event VoiceConnection#streamUpdate * @description Emitted when the StreamConnection or StreamConnectionReadonly * state changes, providing the previous and current stream state. * * @param {StreamState} oldData - The previous state of the stream. * @param {StreamState} newData - The current state of the stream. * * @typedef {Object} StreamState * @property {boolean} isPaused - Indicates whether the stream is currently paused. * @property {string|null} region - The region where the stream is hosted, or null if not specified. * @property {Snowflake[]} viewerIds - An array of Snowflake IDs representing the viewers connected to the stream. */ } /** * Represents a connection to a guild's voice server. * ```js * // Obtained using: * client.voice.joinChannel(channel) * .then(connection => connection.createStreamConnection()) * .then(connection => { * * }); * ``` * @extends {VoiceConnection} */ class StreamConnection extends VoiceConnection { #requestDisconnect = false; /** * @param {ClientVoiceManager} voiceManager Voice manager * @param {Channel} channel any channel (joinable) * @param {VoiceConnection} voiceConnection parent */ constructor(voiceManager, channel, voiceConnection) { super(voiceManager, channel); /** * Current voice connection * @type {VoiceConnection} */ this.voiceConnection = voiceConnection; Object.defineProperty(this, 'voiceConnection', { value: voiceConnection, writable: false, }); /** * Server Id * @type {string | null} */ this.serverId = null; /** * Stream state * @type {boolean} */ this.isPaused = false; /** * Viewer IDs * @type {Snowflake[]} */ this.viewerIds = []; /** * Voice region name * @type {string | null} */ this.region = null; } createStreamConnection() { return Promise.resolve(this); } joinStreamConnection() { throw new Error('STREAM_CANNOT_JOIN'); } get streamConnection() { return this; } set streamConnection(value) { // Why ? } get streamWatchConnection() { return new Collection(); } set streamWatchConnection(value) { // Why ? } disconnect() { if (this.#requestDisconnect) return; this.emit('closing'); this.emit('debug', 'Stream: disconnect() triggered'); clearTimeout(this.connectTimeout); if (this.voiceConnection.streamConnection === this) this.voiceConnection.streamConnection = null; this.sendStopScreenshare(); this._disconnect(); } /** * Create new stream connection (WS packet) * @returns {void} */ sendSignalScreenshare() { const data = { type: ['DM', 'GROUP_DM'].includes(this.channel.type) ? 'call' : 'guild', guild_id: this.channel.guild?.id || null, channel_id: this.channel.id, preferred_region: null, }; this.emit('debug', `Signal Stream: ${JSON.stringify(data)}`); return this.channel.client.ws.broadcast({ op: Opcodes.STREAM_CREATE, d: data, }); } /** * Send screenshare state... (WS) * @param {boolean} isPaused screenshare paused ? * @returns {void} */ sendScreenshareState(isPaused = false) { if (isPaused == this.isPaused) return; this.emit( 'streamUpdate', { isPaused: this.isPaused, region: this.region, viewerIds: this.viewerIds, }, { isPaused, region: this.region, viewerIds: this.viewerIds, }, ); this.isPaused = isPaused; this.channel.client.ws.broadcast({ op: Opcodes.STREAM_SET_PAUSED, d: { stream_key: this.streamKey, paused: isPaused, }, }); } /** * Stop screenshare, delete this connection (WS) * @returns {void} * @private Using StreamConnection#disconnect() */ sendStopScreenshare() { this.#requestDisconnect = true; this.channel.client.ws.broadcast({ op: Opcodes.STREAM_DELETE, d: { stream_key: this.streamKey, }, }); } update(data) { this.emit( 'streamUpdate', { isPaused: this.isPaused, region: this.region, viewerIds: this.viewerIds.slice(), }, { isPaused: data.paused, region: data.region, viewerIds: data.viewer_ids, }, ); this.viewerIds = data.viewer_ids; this.region = data.region; } /** * Current stream key * @type {string} */ get streamKey() { return `${['DM', 'GROUP_DM'].includes(this.channel.type) ? 'call' : `guild:${this.channel.guild.id}`}:${ this.channel.id }:${this.channel.client.user.id}`; } } /** * Represents a connection to a guild's voice server. * ```js * // Obtained using: * client.voice.joinChannel(channel) * .then(connection => connection.createStreamConnection()) * .then(connection => { * * }); * ``` * @extends {VoiceConnection} */ class StreamConnectionReadonly extends VoiceConnection { #requestDisconnect = false; /** * @param {ClientVoiceManager} voiceManager Voice manager * @param {Channel} channel any channel (joinable) * @param {VoiceConnection} voiceConnection parent * @param {Snowflake} userId User ID */ constructor(voiceManager, channel, voiceConnection, userId) { super(voiceManager, channel); /** * Current voice connection * @type {VoiceConnection} */ this.voiceConnection = voiceConnection; /** * User ID (who started the stream) * @type {Snowflake} */ this.userId = userId; Object.defineProperty(this, 'voiceConnection', { value: voiceConnection, writable: false, }); /** * Server Id * @type {string | null} */ this.serverId = null; /** * Stream state * @type {boolean} */ this.isPaused = false; /** * Viewer IDs * @type {Snowflake[]} */ this.viewerIds = []; /** * Voice region name * @type {string | null} */ this.region = null; } createStreamConnection() { throw new Error('STREAM_CONNECTION_READONLY'); } joinStreamConnection() { return Promise.resolve(this); } get streamConnection() { return null; } set streamConnection(value) { // Why ? } get streamWatchConnection() { return new Collection(); } set streamWatchConnection(value) { // Why ? } disconnect() { if (this.#requestDisconnect) return; this.emit('closing'); this.emit('debug', 'Stream: disconnect() triggered'); clearTimeout(this.connectTimeout); this.voiceConnection.streamWatchConnection.delete(this.userId); this.sendStopScreenshare(); this._disconnect(); } /** * Create new stream connection (WS packet) * @returns {void} */ sendSignalScreenshare() { this.emit('debug', `Signal Stream Watch: ${this.streamKey}`); return this.channel.client.ws.broadcast({ op: Opcodes.STREAM_WATCH, d: { stream_key: this.streamKey, }, }); } /** * Stop screenshare, delete this connection (WS) * @returns {void} * @private Using StreamConnection#disconnect() */ sendStopScreenshare() { this.#requestDisconnect = true; this.channel.client.ws.broadcast({ op: Opcodes.STREAM_DELETE, d: { stream_key: this.streamKey, }, }); } update(data) { this.emit( 'streamUpdate', { isPaused: this.isPaused, region: this.region, viewerIds: this.viewerIds.slice(), }, { isPaused: data.paused, region: data.region, viewerIds: data.viewer_ids, }, ); this.isPaused = data.paused; this.viewerIds = data.viewer_ids; this.region = data.region; } /** * Current stream key * @type {string} */ get streamKey() { return `${['DM', 'GROUP_DM'].includes(this.channel.type) ? 'call' : `guild:${this.channel.guild.id}`}:${ this.channel.id }:${this.userId}`; } } PlayInterface.applyToClass(VoiceConnection); PlayInterface.applyToClass(StreamConnection); module.exports = VoiceConnection;