buddy

node MVC discord bot
Log | Files | Refs | README

VoiceConnection.js (14658B)


      1 'use strict';
      2 
      3 const EventEmitter = require('events');
      4 const VoiceUDP = require('./networking/VoiceUDPClient');
      5 const VoiceWebSocket = require('./networking/VoiceWebSocket');
      6 const AudioPlayer = require('./player/AudioPlayer');
      7 const VoiceReceiver = require('./receiver/Receiver');
      8 const PlayInterface = require('./util/PlayInterface');
      9 const Silence = require('./util/Silence');
     10 const { Error } = require('../../errors');
     11 const { OPCodes, VoiceOPCodes, VoiceStatus, Events } = require('../../util/Constants');
     12 const Speaking = require('../../util/Speaking');
     13 const Util = require('../../util/Util');
     14 
     15 // Workaround for Discord now requiring silence to be sent before being able to receive audio
     16 class SingleSilence extends Silence {
     17   _read() {
     18     super._read();
     19     this.push(null);
     20   }
     21 }
     22 
     23 const SUPPORTED_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
     24 
     25 /**
     26  * Represents a connection to a guild's voice server.
     27  * ```js
     28  * // Obtained using:
     29  * voiceChannel.join()
     30  *   .then(connection => {
     31  *
     32  *   });
     33  * ```
     34  * @extends {EventEmitter}
     35  * @implements {PlayInterface}
     36  */
     37 class VoiceConnection extends EventEmitter {
     38   constructor(voiceManager, channel) {
     39     super();
     40 
     41     /**
     42      * The voice manager that instantiated this connection
     43      * @type {ClientVoiceManager}
     44      */
     45     this.voiceManager = voiceManager;
     46 
     47     /**
     48      * The voice channel this connection is currently serving
     49      * @type {VoiceChannel}
     50      */
     51     this.channel = channel;
     52 
     53     /**
     54      * The current status of the voice connection
     55      * @type {VoiceStatus}
     56      */
     57     this.status = VoiceStatus.AUTHENTICATING;
     58 
     59     /**
     60      * Our current speaking state
     61      * @type {Readonly<Speaking>}
     62      */
     63     this.speaking = new Speaking().freeze();
     64 
     65     /**
     66      * The authentication data needed to connect to the voice server
     67      * @type {Object}
     68      * @private
     69      */
     70     this.authentication = {};
     71 
     72     /**
     73      * The audio player for this voice connection
     74      * @type {AudioPlayer}
     75      */
     76     this.player = new AudioPlayer(this);
     77 
     78     this.player.on('debug', m => {
     79       /**
     80        * Debug info from the connection.
     81        * @event VoiceConnection#debug
     82        * @param {string} message The debug message
     83        */
     84       this.emit('debug', `audio player - ${m}`);
     85     });
     86 
     87     this.player.on('error', e => {
     88       /**
     89        * Warning info from the connection.
     90        * @event VoiceConnection#warn
     91        * @param {string|Error} warning The warning
     92        */
     93       this.emit('warn', e);
     94     });
     95 
     96     this.once('closing', () => this.player.destroy());
     97 
     98     /**
     99      * Map SSRC values to user IDs
    100      * @type {Map<number, Snowflake>}
    101      * @private
    102      */
    103     this.ssrcMap = new Map();
    104 
    105     /**
    106      * Tracks which users are talking
    107      * @type {Map<Snowflake, Readonly<Speaking>>}
    108      * @private
    109      */
    110     this._speaking = new Map();
    111 
    112     /**
    113      * Object that wraps contains the `ws` and `udp` sockets of this voice connection
    114      * @type {Object}
    115      * @private
    116      */
    117     this.sockets = {};
    118 
    119     /**
    120      * The voice receiver of this connection
    121      * @type {VoiceReceiver}
    122      */
    123     this.receiver = new VoiceReceiver(this);
    124   }
    125 
    126   /**
    127    * The client that instantiated this connection
    128    * @type {Client}
    129    * @readonly
    130    */
    131   get client() {
    132     return this.voiceManager.client;
    133   }
    134 
    135   /**
    136    * The current stream dispatcher (if any)
    137    * @type {?StreamDispatcher}
    138    * @readonly
    139    */
    140   get dispatcher() {
    141     return this.player.dispatcher;
    142   }
    143 
    144   /**
    145    * Sets whether the voice connection should display as "speaking", "soundshare" or "none".
    146    * @param {BitFieldResolvable} value The new speaking state
    147    * @private
    148    */
    149   setSpeaking(value) {
    150     if (this.speaking.equals(value)) return;
    151     if (this.status !== VoiceStatus.CONNECTED) return;
    152     this.speaking = new Speaking(value).freeze();
    153     this.sockets.ws
    154       .sendPacket({
    155         op: VoiceOPCodes.SPEAKING,
    156         d: {
    157           speaking: this.speaking.bitfield,
    158           delay: 0,
    159           ssrc: this.authentication.ssrc,
    160         },
    161       })
    162       .catch(e => {
    163         this.emit('debug', e);
    164       });
    165   }
    166 
    167   /**
    168    * The voice state of this connection
    169    * @type {VoiceState}
    170    */
    171   get voice() {
    172     return this.channel.guild.voice;
    173   }
    174 
    175   /**
    176    * Sends a request to the main gateway to join a voice channel.
    177    * @param {Object} [options] The options to provide
    178    * @returns {Promise<Shard>}
    179    * @private
    180    */
    181   sendVoiceStateUpdate(options = {}) {
    182     options = Util.mergeDefault(
    183       {
    184         guild_id: this.channel.guild.id,
    185         channel_id: this.channel.id,
    186         self_mute: this.voice ? this.voice.selfMute : false,
    187         self_deaf: this.voice ? this.voice.selfDeaf : false,
    188       },
    189       options,
    190     );
    191 
    192     this.emit('debug', `Sending voice state update: ${JSON.stringify(options)}`);
    193 
    194     return this.channel.guild.shard.send(
    195       {
    196         op: OPCodes.VOICE_STATE_UPDATE,
    197         d: options,
    198       },
    199       true,
    200     );
    201   }
    202 
    203   /**
    204    * Set the token and endpoint required to connect to the voice servers.
    205    * @param {string} token The voice token
    206    * @param {string} endpoint The voice endpoint
    207    * @private
    208    * @returns {void}
    209    */
    210   setTokenAndEndpoint(token, endpoint) {
    211     this.emit('debug', `Token "${token}" and endpoint "${endpoint}"`);
    212     if (!endpoint) {
    213       // Signifies awaiting endpoint stage
    214       return;
    215     }
    216 
    217     if (!token) {
    218       this.authenticateFailed('VOICE_TOKEN_ABSENT');
    219       return;
    220     }
    221 
    222     endpoint = endpoint.match(/([^:]*)/)[0];
    223     this.emit('debug', `Endpoint resolved as ${endpoint}`);
    224 
    225     if (!endpoint) {
    226       this.authenticateFailed('VOICE_INVALID_ENDPOINT');
    227       return;
    228     }
    229 
    230     if (this.status === VoiceStatus.AUTHENTICATING) {
    231       this.authentication.token = token;
    232       this.authentication.endpoint = endpoint;
    233       this.checkAuthenticated();
    234     } else if (token !== this.authentication.token || endpoint !== this.authentication.endpoint) {
    235       this.reconnect(token, endpoint);
    236     }
    237   }
    238 
    239   /**
    240    * Sets the Session ID for the connection.
    241    * @param {string} sessionID The voice session ID
    242    * @private
    243    */
    244   setSessionID(sessionID) {
    245     this.emit('debug', `Setting sessionID ${sessionID} (stored as "${this.authentication.sessionID}")`);
    246     if (!sessionID) {
    247       this.authenticateFailed('VOICE_SESSION_ABSENT');
    248       return;
    249     }
    250 
    251     if (this.status === VoiceStatus.AUTHENTICATING) {
    252       this.authentication.sessionID = sessionID;
    253       this.checkAuthenticated();
    254     } else if (sessionID !== this.authentication.sessionID) {
    255       this.authentication.sessionID = sessionID;
    256       /**
    257        * Emitted when a new session ID is received.
    258        * @event VoiceConnection#newSession
    259        * @private
    260        */
    261       this.emit('newSession', sessionID);
    262     }
    263   }
    264 
    265   /**
    266    * Checks whether the voice connection is authenticated.
    267    * @private
    268    */
    269   checkAuthenticated() {
    270     const { token, endpoint, sessionID } = this.authentication;
    271     this.emit('debug', `Authenticated with sessionID ${sessionID}`);
    272     if (token && endpoint && sessionID) {
    273       this.status = VoiceStatus.CONNECTING;
    274       /**
    275        * Emitted when we successfully initiate a voice connection.
    276        * @event VoiceConnection#authenticated
    277        */
    278       this.emit('authenticated');
    279       this.connect();
    280     }
    281   }
    282 
    283   /**
    284    * Invoked when we fail to initiate a voice connection.
    285    * @param {string} reason The reason for failure
    286    * @private
    287    */
    288   authenticateFailed(reason) {
    289     this.client.clearTimeout(this.connectTimeout);
    290     this.emit('debug', `Authenticate failed - ${reason}`);
    291     if (this.status === VoiceStatus.AUTHENTICATING) {
    292       /**
    293        * Emitted when we fail to initiate a voice connection.
    294        * @event VoiceConnection#failed
    295        * @param {Error} error The encountered error
    296        */
    297       this.emit('failed', new Error(reason));
    298     } else {
    299       /**
    300        * Emitted whenever the connection encounters an error.
    301        * @event VoiceConnection#error
    302        * @param {Error} error The encountered error
    303        */
    304       this.emit('error', new Error(reason));
    305     }
    306     this.status = VoiceStatus.DISCONNECTED;
    307   }
    308 
    309   /**
    310    * Move to a different voice channel in the same guild.
    311    * @param {VoiceChannel} channel The channel to move to
    312    * @private
    313    */
    314   updateChannel(channel) {
    315     this.channel = channel;
    316     this.sendVoiceStateUpdate();
    317   }
    318 
    319   /**
    320    * Attempts to authenticate to the voice server.
    321    * @private
    322    */
    323   authenticate() {
    324     this.sendVoiceStateUpdate();
    325     this.connectTimeout = this.client.setTimeout(() => this.authenticateFailed('VOICE_CONNECTION_TIMEOUT'), 15000);
    326   }
    327 
    328   /**
    329    * Attempts to reconnect to the voice server (typically after a region change).
    330    * @param {string} token The voice token
    331    * @param {string} endpoint The voice endpoint
    332    * @private
    333    */
    334   reconnect(token, endpoint) {
    335     this.authentication.token = token;
    336     this.authentication.endpoint = endpoint;
    337     this.speaking = new Speaking().freeze();
    338     this.status = VoiceStatus.RECONNECTING;
    339     this.emit('debug', `Reconnecting to ${endpoint}`);
    340     /**
    341      * Emitted when the voice connection is reconnecting (typically after a region change).
    342      * @event VoiceConnection#reconnecting
    343      */
    344     this.emit('reconnecting');
    345     this.connect();
    346   }
    347 
    348   /**
    349    * Disconnects the voice connection, causing a disconnect and closing event to be emitted.
    350    */
    351   disconnect() {
    352     this.emit('closing');
    353     this.emit('debug', 'disconnect() triggered');
    354     this.client.clearTimeout(this.connectTimeout);
    355     const conn = this.voiceManager.connections.get(this.channel.guild.id);
    356     if (conn === this) this.voiceManager.connections.delete(this.channel.guild.id);
    357     this.sendVoiceStateUpdate({
    358       channel_id: null,
    359     });
    360     this._disconnect();
    361   }
    362 
    363   /**
    364    * Internally disconnects (doesn't send disconnect packet).
    365    * @private
    366    */
    367   _disconnect() {
    368     this.cleanup();
    369     this.status = VoiceStatus.DISCONNECTED;
    370     /**
    371      * Emitted when the voice connection disconnects.
    372      * @event VoiceConnection#disconnect
    373      */
    374     this.emit('disconnect');
    375   }
    376 
    377   /**
    378    * Cleans up after disconnect.
    379    * @private
    380    */
    381   cleanup() {
    382     this.player.destroy();
    383     this.speaking = new Speaking().freeze();
    384     const { ws, udp } = this.sockets;
    385 
    386     this.emit('debug', 'Connection clean up');
    387 
    388     if (ws) {
    389       ws.removeAllListeners('error');
    390       ws.removeAllListeners('ready');
    391       ws.removeAllListeners('sessionDescription');
    392       ws.removeAllListeners('speaking');
    393       ws.shutdown();
    394     }
    395 
    396     if (udp) udp.removeAllListeners('error');
    397 
    398     this.sockets.ws = null;
    399     this.sockets.udp = null;
    400   }
    401 
    402   /**
    403    * Connect the voice connection.
    404    * @private
    405    */
    406   connect() {
    407     this.emit('debug', `Connect triggered`);
    408     if (this.status !== VoiceStatus.RECONNECTING) {
    409       if (this.sockets.ws) throw new Error('WS_CONNECTION_EXISTS');
    410       if (this.sockets.udp) throw new Error('UDP_CONNECTION_EXISTS');
    411     }
    412 
    413     if (this.sockets.ws) this.sockets.ws.shutdown();
    414     if (this.sockets.udp) this.sockets.udp.shutdown();
    415 
    416     this.sockets.ws = new VoiceWebSocket(this);
    417     this.sockets.udp = new VoiceUDP(this);
    418 
    419     const { ws, udp } = this.sockets;
    420 
    421     ws.on('debug', msg => this.emit('debug', msg));
    422     udp.on('debug', msg => this.emit('debug', msg));
    423     ws.on('error', err => this.emit('error', err));
    424     udp.on('error', err => this.emit('error', err));
    425     ws.on('ready', this.onReady.bind(this));
    426     ws.on('sessionDescription', this.onSessionDescription.bind(this));
    427     ws.on('startSpeaking', this.onStartSpeaking.bind(this));
    428 
    429     this.sockets.ws.connect();
    430   }
    431 
    432   /**
    433    * Invoked when the voice websocket is ready.
    434    * @param {Object} data The received data
    435    * @private
    436    */
    437   onReady(data) {
    438     Object.assign(this.authentication, data);
    439     for (let mode of data.modes) {
    440       if (SUPPORTED_MODES.includes(mode)) {
    441         this.authentication.mode = mode;
    442         this.emit('debug', `Selecting the ${mode} mode`);
    443         break;
    444       }
    445     }
    446     this.sockets.udp.createUDPSocket(data.ip);
    447   }
    448 
    449   /**
    450    * Invoked when a session description is received.
    451    * @param {Object} data The received data
    452    * @private
    453    */
    454   onSessionDescription(data) {
    455     Object.assign(this.authentication, data);
    456     this.status = VoiceStatus.CONNECTED;
    457     const ready = () => {
    458       this.client.clearTimeout(this.connectTimeout);
    459       this.emit('debug', `Ready with authentication details: ${JSON.stringify(this.authentication)}`);
    460       /**
    461        * Emitted once the connection is ready, when a promise to join a voice channel resolves,
    462        * the connection will already be ready.
    463        * @event VoiceConnection#ready
    464        */
    465       this.emit('ready');
    466     };
    467     if (this.dispatcher) {
    468       ready();
    469     } else {
    470       // This serves to provide support for voice receive, sending audio is required to receive it.
    471       const dispatcher = this.play(new SingleSilence(), { type: 'opus', volume: false });
    472       dispatcher.once('finish', ready);
    473     }
    474   }
    475 
    476   onStartSpeaking({ user_id, ssrc, speaking }) {
    477     this.ssrcMap.set(+ssrc, { userID: user_id, speaking: speaking });
    478   }
    479 
    480   /**
    481    * Invoked when a speaking event is received.
    482    * @param {Object} data The received data
    483    * @private
    484    */
    485   onSpeaking({ user_id, speaking }) {
    486     speaking = new Speaking(speaking).freeze();
    487     const guild = this.channel.guild;
    488     const user = this.client.users.cache.get(user_id);
    489     const old = this._speaking.get(user_id);
    490     this._speaking.set(user_id, speaking);
    491     /**
    492      * Emitted whenever a user changes speaking state.
    493      * @event VoiceConnection#speaking
    494      * @param {User} user The user that has changed speaking state
    495      * @param {Readonly<Speaking>} speaking The speaking state of the user
    496      */
    497     if (this.status === VoiceStatus.CONNECTED) {
    498       this.emit('speaking', user, speaking);
    499       if (!speaking.has(Speaking.FLAGS.SPEAKING)) {
    500         this.receiver.packets._stoppedSpeaking(user_id);
    501       }
    502     }
    503 
    504     if (guild && user && !speaking.equals(old)) {
    505       const member = guild.member(user);
    506       if (member) {
    507         /**
    508          * Emitted once a guild member changes speaking state.
    509          * @event Client#guildMemberSpeaking
    510          * @param {GuildMember} member The member that started/stopped speaking
    511          * @param {Readonly<Speaking>} speaking The speaking state of the member
    512          */
    513         this.client.emit(Events.GUILD_MEMBER_SPEAKING, member, speaking);
    514       }
    515     }
    516   }
    517 
    518   play() {} // eslint-disable-line no-empty-function
    519 }
    520 
    521 PlayInterface.applyToClass(VoiceConnection);
    522 
    523 module.exports = VoiceConnection;