detritus-client-socket
Version:
A TypeScript NodeJS library to interact with Discord's Gateway
613 lines (612 loc) • 22.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Socket = void 0;
const detritus_utils_1 = require("detritus-utils");
const basesocket_1 = require("./basesocket");
const bucket_1 = require("./bucket");
const constants_1 = require("./constants");
const mediaudp_1 = require("./mediaudp");
const defaultOptions = {
receive: false,
serverId: null,
userId: null,
video: false,
};
class Socket extends detritus_utils_1.EventSpewer {
constructor(gateway, options) {
super();
this.state = constants_1.SocketStates.CLOSED;
this._heartbeat = {
ack: false,
lastAck: null,
lastSent: null,
interval: new detritus_utils_1.Timers.Interval(),
intervalTime: null,
nonce: null,
};
this.bucket = new bucket_1.Bucket(120, 60 * 1000);
this.endpoint = null;
this.forceMode = null;
this.identified = false;
this.killed = false;
this.promises = new Set();
this.protocol = null;
this.ready = false;
this.receiveEnabled = false;
this.reconnects = 0;
this.socket = null;
this.ssrcs = {
[constants_1.MediaSSRCTypes.AUDIO]: new Map(),
[constants_1.MediaSSRCTypes.VIDEO]: new Map(),
};
this.transport = null;
this.token = null;
this.gateway = gateway;
options = Object.assign({}, defaultOptions, options);
this.channelId = options.channelId;
this.serverId = options.serverId;
this.userId = options.userId;
this.receiveEnabled = !!options.receive;
this.videoEnabled = !!options.video;
if (options.forceMode !== undefined) {
if (!constants_1.MEDIA_ENCRYPTION_MODES.includes(options.forceMode)) {
throw new Error('Unknown Encryption Mode');
}
this.forceMode = options.forceMode;
}
Object.defineProperties(this, {
_heartbeat: { enumerable: false, writable: false },
channelId: { configurable: true, writable: false },
gateway: { enumerable: false, writable: false },
killed: { configurable: true, writable: false },
protocol: { configurable: true, writable: false },
ready: { configurable: true, writable: false },
serverId: { writable: false },
state: { configurable: true, writable: false },
token: { configurable: true, writable: false },
userId: { writable: false },
});
this.setProtocol(constants_1.MediaProtocols.UDP);
}
get closed() {
return !!this.socket && this.socket.closed;
}
get closing() {
return !!this.socket && this.socket.closing;
}
get connected() {
return !!this.socket && this.socket.connected;
}
get connecting() {
return !!this.socket && this.socket.connecting;
}
get guildId() {
return (this.inDm) ? null : this.serverId;
}
get inDm() {
return this.serverId === this.channelId;
}
get sessionId() {
return this.gateway.sessionId;
}
get audioSSRC() {
return (this.transport) ? this.transport.ssrc : 0;
}
get videoSSRC() {
return (this.videoEnabled) ? this.audioSSRC + 1 : 0;
}
get rtxSSRC() {
return (this.videoEnabled) ? this.videoSSRC + 1 : 0;
}
resolvePromises(error) {
this.promises.forEach((promise) => {
this.promises.delete(promise);
if (error) {
promise.reject(error);
}
else {
promise.resolve();
}
});
}
setChannelId(value) {
Object.defineProperty(this, 'channelId', { value });
}
setEndpoint(value) {
this.endpoint = (value) ? `wss://${value.split(':').shift()}` : null;
this.identified = false;
if (this.connected) {
this.connect();
}
}
setProtocol(value) {
if (this.transport) {
throw new Error('Cannot change protocols after transport connection.');
}
if (value !== constants_1.MediaProtocols.UDP) {
throw new Error('UDP is currently the only protocol supported.');
}
if (!constants_1.MEDIA_PROTOCOLS.includes(value)) {
throw new Error('Invalid Protocol Type');
}
Object.defineProperty(this, 'protocol', { value });
}
setState(value) {
if (value in constants_1.SocketStates && value !== this.state) {
Object.defineProperty(this, 'state', { value });
this.emit(constants_1.SocketEvents.STATE, { state: value });
}
}
setToken(value) {
Object.defineProperty(this, 'token', { value });
if (!this.identified) {
this.resolvePromises();
this.connect();
}
}
ssrcToUserId(ssrc, type = constants_1.MediaSSRCTypes.AUDIO) {
if (!(type in this.ssrcs)) {
throw new Error('Invalid SSRC Type');
}
if (this.ssrcs[type].has(ssrc)) {
return this.ssrcs[type].get(ssrc);
}
return null;
}
userIdToSSRC(userId, type = constants_1.MediaSSRCTypes.AUDIO) {
if (!(type in this.ssrcs)) {
throw new Error(`Invalid SSRC Type`);
}
for (let [ssrc, uid] of this.ssrcs[type]) {
if (userId === uid) {
return ssrc;
}
}
return null;
}
cleanup(code) {
Object.defineProperty(this, 'ready', { value: false });
this.bucket.clear();
this.bucket.lock();
this.ssrcs[constants_1.MediaSSRCTypes.AUDIO].clear();
this.ssrcs[constants_1.MediaSSRCTypes.VIDEO].clear();
// unresumable events
// 1000 Normal Disconnected
// 4014 Voice Channel Kick/Deleted
// 4015 Voice Server Crashed
switch (code) {
case constants_1.SocketCloseCodes.NORMAL:
case constants_1.SocketMediaCloseCodes.DISCONNECTED:
case constants_1.SocketMediaCloseCodes.VOICE_SERVER_CRASHED:
{
this.identified = false;
}
;
break;
}
this._heartbeat.interval.stop();
this._heartbeat.ack = false;
this._heartbeat.lastAck = null;
this._heartbeat.lastSent = null;
this._heartbeat.intervalTime = null;
this._heartbeat.nonce = null;
}
connect(endpoint) {
if (this.killed) {
return;
}
if (this.connected) {
this.disconnect();
}
if (endpoint) {
this.setEndpoint(endpoint);
}
if (!this.endpoint) {
throw new Error('Media Endpoint is null');
}
const url = new URL(this.endpoint);
url.searchParams.set('v', String(constants_1.ApiVersions.MEDIA_GATEWAY));
url.pathname = url.pathname || '/';
const ws = this.socket = new basesocket_1.BaseSocket(url.href);
this.setState(constants_1.SocketStates.CONNECTING);
this.emit(constants_1.SocketEvents.SOCKET, ws);
ws.socket.onclose = this.onClose.bind(this, ws);
ws.socket.onerror = this.onError.bind(this, ws);
ws.socket.onmessage = this.onMessage.bind(this, ws);
ws.socket.onopen = this.onOpen.bind(this, ws);
}
decode(data) {
try {
return JSON.parse(data);
}
catch (error) {
this.emit(constants_1.SocketEvents.WARN, error);
}
}
disconnect(code = constants_1.SocketCloseCodes.NORMAL, reason) {
this.cleanup(code);
if (this.socket) {
if (!reason && (code in constants_1.SocketInternalCloseReasons)) {
reason = constants_1.SocketInternalCloseReasons[code];
}
this.socket.close(code, reason);
this.socket = null;
}
}
encode(data) {
try {
return JSON.stringify(data);
}
catch (error) {
this.emit(constants_1.SocketEvents.WARN, error);
}
return null;
}
handle(data) {
const packet = this.decode(data);
if (!packet) {
return;
}
this.emit(constants_1.SocketEvents.PACKET, packet);
switch (packet.op) {
case constants_1.MediaOpCodes.READY:
{
const data = packet.d;
this.reconnects = 0;
Object.defineProperty(this, 'ready', { value: true });
this.identified = true;
this.bucket.unlock();
this.transportConnect(data);
this.setState(constants_1.SocketStates.READY);
this.emit(constants_1.SocketEvents.READY);
}
;
break;
case constants_1.MediaOpCodes.RESUMED:
{
this.reconnects = 0;
Object.defineProperty(this, 'ready', { value: true });
this.bucket.unlock();
this.setState(constants_1.SocketStates.READY);
this.emit(constants_1.SocketEvents.READY);
}
;
break;
case constants_1.MediaOpCodes.CLIENT_CONNECT:
{
const data = packet.d;
this.ssrcs[constants_1.MediaSSRCTypes.AUDIO].set(data.audio_ssrc, data.user_id);
if (data['video_ssrc'] !== undefined) {
this.ssrcs[constants_1.MediaSSRCTypes.VIDEO].set(data.video_ssrc, data.user_id);
}
// start the user id's decode/encoders
}
;
break;
case constants_1.MediaOpCodes.CLIENT_DISCONNECT:
{
const data = packet.d;
const audioSSRC = this.userIdToSSRC(data.user_id, constants_1.MediaSSRCTypes.AUDIO);
if (audioSSRC !== null) {
this.ssrcs[constants_1.MediaSSRCTypes.AUDIO].delete(audioSSRC);
}
const videoSSRC = this.userIdToSSRC(data.user_id, constants_1.MediaSSRCTypes.VIDEO);
if (videoSSRC !== null) {
this.ssrcs[constants_1.MediaSSRCTypes.VIDEO].delete(videoSSRC);
}
}
;
break;
case constants_1.MediaOpCodes.HELLO:
{
const data = packet.d;
this.setHeartbeat(data);
}
;
break;
case constants_1.MediaOpCodes.HEARTBEAT_ACK:
{
const data = packet.d;
if (data !== this._heartbeat.nonce) {
this.disconnect(constants_1.SocketInternalCloseCodes.HEARTBEAT_ACK_NONCE);
this.connect();
return;
}
this._heartbeat.ack = true;
this._heartbeat.lastAck = Date.now();
}
;
break;
case constants_1.MediaOpCodes.SELECT_PROTOCOL_ACK:
{
if (this.protocol === constants_1.MediaProtocols.UDP) {
const { audio_codec: audioCodec, mode, media_session_id: mediaSessionId, secret_key: secretKey, video_codec: videoCodec, } = packet.d;
this.transport
.setAudioCodec(audioCodec)
.setVideoCodec(videoCodec)
.setKey(secretKey)
.setMode(mode)
.setTransportId(mediaSessionId);
this.emit(constants_1.SocketEvents.TRANSPORT_READY, this.transport);
}
else if (this.protocol === constants_1.MediaProtocols.WEBRTC) {
const data = packet.d;
}
}
;
break;
case constants_1.MediaOpCodes.SESSION_UPDATE:
{
const { audio_codec: audioCodec, media_session_id: mediaSessionId, video_codec: videoCodec, video_quality_changes: videoQualityChanges, } = packet.d;
this.transport
.setAudioCodec(audioCodec)
.setVideoCodec(videoCodec)
.setTransportId(mediaSessionId);
if (videoQualityChanges) {
videoQualityChanges.forEach((change) => {
});
}
}
;
break;
case constants_1.MediaOpCodes.SPEAKING:
{
const data = packet.d;
this.ssrcs[constants_1.MediaSSRCTypes.AUDIO].set(data.ssrc, data.user_id);
// use the bitmasks Constants.Discord.SpeakingFlags
// emit it?
// check to see if it already existed, if not, create decode/encoders
}
;
break;
case constants_1.MediaOpCodes.VIDEO_SINK_WANTS:
{
const data = packet.d;
}
;
break;
}
}
kill(error) {
if (this.killed) {
return;
}
const serverId = (this.inDm) ? null : this.serverId;
this.gateway.voiceStateUpdate(serverId, null);
Object.defineProperty(this, 'killed', { value: true });
this.gateway.mediaGateways.delete(this.serverId);
this.disconnect(constants_1.SocketCloseCodes.NORMAL);
if (this.transport) {
this.transport.disconnect();
this.transport.removeAllListeners();
this.transport = null;
}
this.resolvePromises(error || new Error('Media Gateway was killed.'));
this.emit(constants_1.SocketEvents.KILLED);
this.removeAllListeners();
}
onClose(target, event) {
let { code, reason } = event;
if (!reason && (code in constants_1.SocketInternalCloseReasons)) {
reason = constants_1.SocketInternalCloseReasons[code];
}
this.emit(constants_1.SocketEvents.CLOSE, { code, reason });
if (!this.socket || this.socket === target) {
this.setState(constants_1.SocketStates.CLOSED);
this.cleanup(code);
if (this.gateway.autoReconnect && !this.killed) {
if (this.gateway.reconnectMax < this.reconnects) {
this.kill();
}
else {
this.emit(constants_1.SocketEvents.RECONNECTING);
setTimeout(() => {
this.connect();
this.reconnects++;
}, this.gateway.reconnectDelay);
}
}
}
}
onError(target, event) {
this.emit(constants_1.SocketEvents.WARN, event.error);
}
onMessage(target, event) {
if (this.socket === target) {
const { data } = event;
this.handle(data);
}
else {
target.close(constants_1.SocketInternalCloseCodes.OTHER_SOCKET_MESSAGE);
}
}
onOpen(target) {
this.emit(constants_1.SocketEvents.OPEN, target);
if (this.socket === target) {
this.setState(constants_1.SocketStates.OPEN);
if (this.identified && this.transport) {
this.resume();
}
else {
this.identify();
}
}
else {
target.close(constants_1.SocketInternalCloseCodes.OTHER_SOCKET_OPEN);
}
}
async ping(timeout) {
if (!this.connected) {
throw new Error('Socket is still initializing!');
}
return this.socket.ping(timeout);
}
send(op, d, callback, direct = false) {
if (!this.connected) {
return;
}
const data = this.encode({ op, d });
if (!data) {
return;
}
if (direct) {
this.socket.send(data, callback);
}
else {
const throttled = () => {
if (this.bucket.locked || !this.identified || !this.connected) {
if (!this.bucket.locked) {
this.bucket.lock();
}
this.bucket.add(throttled, true);
return;
}
try {
this.socket.send(data, callback);
}
catch (error) {
this.emit(constants_1.SocketEvents.WARN, error);
}
};
this.bucket.add(throttled);
}
}
heartbeat(fromInterval = false) {
if (fromInterval && (this._heartbeat.lastSent && !this._heartbeat.ack)) {
this.disconnect(constants_1.SocketInternalCloseCodes.HEARTBEAT_ACK);
this.connect();
}
else {
this._heartbeat.ack = false;
this._heartbeat.lastSent = Date.now();
this._heartbeat.nonce = Date.now();
this.send(constants_1.MediaOpCodes.HEARTBEAT, this._heartbeat.nonce, undefined, true);
}
}
setHeartbeat(data) {
if (!data || !data.heartbeat_interval) {
return;
}
this.heartbeat();
this._heartbeat.ack = true;
this._heartbeat.lastAck = Date.now();
this._heartbeat.intervalTime = data.heartbeat_interval;
this._heartbeat.interval.start(this._heartbeat.intervalTime, () => {
this.heartbeat(true);
});
}
identify() {
if (this.state !== constants_1.SocketStates.OPEN) {
return;
}
this.send(constants_1.MediaOpCodes.IDENTIFY, {
server_id: this.serverId,
session_id: this.sessionId,
token: this.token,
user_id: this.userId,
video: this.videoEnabled,
}, () => {
this.setState(constants_1.SocketStates.IDENTIFYING);
}, true);
}
resume() {
this.send(constants_1.MediaOpCodes.RESUME, {
server_id: this.serverId,
session_id: this.sessionId,
token: this.token,
}, () => {
this.setState(constants_1.SocketStates.RESUMING);
}, true);
}
transportConnect(data) {
this.ssrcs[constants_1.MediaSSRCTypes.AUDIO].set(data.ssrc, this.gateway.userId);
if (!this.transport) {
if (this.protocol === constants_1.MediaProtocols.UDP) {
this.transport = new mediaudp_1.Socket(this);
}
else {
this.emit(constants_1.SocketEvents.WARN, new Error(`Unsupported Media Transport Protocol: ${this.protocol}`));
return;
}
}
else {
this.transport.disconnect();
}
if (this.protocol === constants_1.MediaProtocols.UDP) {
let mode = null;
if (this.forceMode && constants_1.MEDIA_ENCRYPTION_MODES.includes(this.forceMode)) {
mode = this.forceMode;
}
else {
for (let value of data.modes) {
let m = value;
if (constants_1.MEDIA_ENCRYPTION_MODES.includes(m)) {
mode = m;
break;
}
}
}
let transport = this.transport;
if (mode) {
transport.setMode(mode);
transport.setSSRC(data.ssrc);
transport.connect(data.ip, data.port);
this.emit(constants_1.SocketEvents.TRANSPORT, transport);
}
else {
transport.disconnect();
this.transport = null;
this.emit(constants_1.SocketEvents.WARN, new Error(`No supported voice mode found in ${JSON.stringify(data.modes)}`));
}
}
else {
this.emit(constants_1.SocketEvents.WARN, new Error(`Unsupported Media Transport Protocol: ${this.protocol}`));
}
}
sendClientConnect(callback) {
this.send(constants_1.MediaOpCodes.CLIENT_CONNECT, {
audio_ssrc: this.audioSSRC,
video_ssrc: this.videoSSRC,
rtx_ssrc: this.rtxSSRC,
}, callback);
}
sendSelectProtocol(options, callback) {
options = Object.assign({
protocol: constants_1.MediaProtocols.UDP,
}, options);
const data = {
codecs: options.codecs,
data: options.data,
experiments: options.experiments,
protocol: options.protocol,
rtc_connection_id: options.rtcConnectionId,
};
this.send(constants_1.MediaOpCodes.SELECT_PROTOCOL, data, callback);
}
sendSpeaking(options, callback) {
options = Object.assign({
delay: 0,
ssrc: this.transport.ssrc,
}, options);
const data = {
delay: options.delay,
ssrc: options.ssrc,
speaking: constants_1.MediaSpeakingFlags.NONE,
};
if (options.soundshare) {
data.speaking |= constants_1.MediaSpeakingFlags.SOUNDSHARE;
}
if (options.voice) {
data.speaking |= constants_1.MediaSpeakingFlags.VOICE;
}
this.send(constants_1.MediaOpCodes.SPEAKING, data, callback);
}
sendStateUpdate(options = {}, callback) {
this.gateway.voiceStateUpdate(this.guildId, this.channelId, options, callback);
}
on(event, listener) {
super.on(event, listener);
return this;
}
}
exports.Socket = Socket;