detritus-client-socket
Version:
A TypeScript NodeJS library to interact with Discord's Gateway
644 lines (642 loc) • 24.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Socket = void 0;
const dgram = require("dgram");
const detritus_utils_1 = require("detritus-utils");
const constants_1 = require("./constants");
const errors_1 = require("./errors");
const rtp_1 = require("./mediapackets/rtp");
const rtpcrypto_1 = require("./mediapackets/rtpcrypto");
;
class Socket extends detritus_utils_1.EventSpewer {
constructor(mediaGateway) {
super();
this.mediaGateway = mediaGateway;
this.connected = false;
this.key = null;
this.mode = null;
this.socket = null;
this.ssrc = 0;
this.transportId = null;
Object.defineProperty(this, 'mediaGateway', {
enumerable: false,
writable: false,
});
this.caches = { audio: Buffer.alloc(5 * 1024) };
this.headers = { audio: new rtp_1.RTPHeader({ randomize: true }) };
this.nonces = { audio: new rtp_1.RTPNonce({ randomize: true }) };
if (this.videoEnabled) {
this.caches.video = Buffer.alloc(5 * 1024);
this.headers.video = new rtp_1.RTPHeader({ randomize: true });
this.nonces.video = new rtp_1.RTPNonce({ randomize: true });
}
this.codecs = {
audio: null,
video: null,
};
this.local = {
ip: null,
port: null,
};
this.remote = {
ip: null,
port: null,
};
}
get audioSSRC() {
return this.mediaGateway.audioSSRC;
}
get videoSSRC() {
return this.mediaGateway.videoSSRC;
}
get rtxSSRC() {
return this.mediaGateway.rtxSSRC;
}
get rtpAudioPayloadType() {
switch (this.codecs.audio) {
case constants_1.MediaCodecs.OPUS:
{
return constants_1.RTPPayloadTypes.OPUS;
}
;
}
return null;
}
get rtpVideoPayloadType() {
switch (this.codecs.video) {
case constants_1.MediaCodecs.VP8:
{
return constants_1.RTPPayloadTypes.VP8;
}
;
case constants_1.MediaCodecs.VP9:
{
return constants_1.RTPPayloadTypes.VP9;
}
;
case constants_1.MediaCodecs.H264:
{
return constants_1.RTPPayloadTypes.H264;
}
;
}
return null;
}
get rtpRTXPayloadType() {
const payloadType = this.rtpVideoPayloadType;
if (payloadType !== null) {
return payloadType + 1;
}
return null;
}
get receiveEnabled() {
return this.mediaGateway.receiveEnabled;
}
get videoEnabled() {
return this.mediaGateway.videoEnabled;
}
setAudioCodec(codec) {
if (!codec) {
return this;
}
if (!constants_1.MEDIA_CODECS_AUDIO.includes(codec)) {
this.emit(constants_1.SocketEvents.WARN, new Error(`Unsupported audio codec received: ${codec}`));
this.mediaGateway.kill();
return this;
}
this.codecs.audio = codec;
this.headers.audio.setPayloadType(this.rtpAudioPayloadType);
return this;
}
setVideoCodec(codec) {
if (!codec) {
return this;
}
if (!constants_1.MEDIA_CODECS_VIDEO.includes(codec)) {
this.emit(constants_1.SocketEvents.WARN, new Error(`Unsupported video codec received: ${codec}`));
this.mediaGateway.kill();
return this;
}
this.codecs.video = codec;
if (this.headers.video) {
this.headers.video.setPayloadType(this.rtpVideoPayloadType);
}
return this;
}
setKey(value) {
Object.defineProperty(this, 'key', {
value: Uint8Array.from(value),
});
return this;
}
setMode(value) {
if (!constants_1.MEDIA_ENCRYPTION_MODES.includes(value)) {
throw new Error(`Encryption mode '${value}' is not supported.`);
}
Object.defineProperty(this, 'mode', { value });
return this;
}
setSSRC(value) {
Object.defineProperty(this, 'ssrc', { value });
this.headers.audio.setSSRC(this.audioSSRC);
if (this.headers.video) {
this.headers.video.setSSRC(this.videoSSRC);
}
return this;
}
setTransportId(value) {
Object.defineProperty(this, 'transportId', { value });
return this;
}
connect(ip = null, port = null) {
this.remote.ip = ip || this.remote.ip;
this.remote.port = port || this.remote.port;
if (this.connected) {
this.disconnect();
}
const onPacket = this.onPacket.bind(this);
const socket = this.socket = dgram.createSocket('udp4');
this.emit(constants_1.SocketEvents.SOCKET, socket);
socket.once('message', (packet) => {
if (this.ssrc !== packet.readUInt32LE(0)) {
this.emit(constants_1.SocketEvents.WARN, new Error('SSRC mismatch in ip discovery packet'));
return;
}
this.local.ip = packet.slice(4, packet.indexOf(0, 4)).toString();
this.local.port = packet.readUIntLE(packet.length - 2, 2);
const codecs = [];
constants_1.MEDIA_CODECS_AUDIO.forEach((codec, i) => {
let rtpPayloadType = 0;
switch (codec) {
case constants_1.MediaCodecs.OPUS:
{
rtpPayloadType = constants_1.RTPPayloadTypes.OPUS;
}
;
break;
}
codecs.push({
name: codec,
payload_type: rtpPayloadType,
priority: (i + 1) * 1000,
type: constants_1.MediaCodecTypes.AUDIO,
});
});
if (this.videoEnabled) {
constants_1.MEDIA_CODECS_VIDEO.forEach((codec, i) => {
let rtpPayloadType = 0;
switch (codec) {
case constants_1.MediaCodecs.VP8:
{
rtpPayloadType = constants_1.RTPPayloadTypes.VP8;
}
;
break;
case constants_1.MediaCodecs.VP9:
{
rtpPayloadType = constants_1.RTPPayloadTypes.VP9;
}
;
break;
case constants_1.MediaCodecs.H264:
{
rtpPayloadType = constants_1.RTPPayloadTypes.H264;
}
;
break;
}
codecs.push({
name: codec,
payload_type: rtpPayloadType,
priority: (i + 1) * 1000,
rtx_payload_type: rtpPayloadType + 1,
type: constants_1.MediaCodecTypes.VIDEO,
});
});
}
this.mediaGateway.sendSelectProtocol({
codecs,
data: {
address: this.local.ip,
mode: this.mode,
port: this.local.port,
},
protocol: constants_1.MediaProtocols.UDP,
});
this.mediaGateway.sendClientConnect();
socket.on('message', onPacket);
this.emit(constants_1.SocketEvents.READY);
});
socket.on('close', () => {
this.connected = false;
socket.removeListener('message', onPacket);
this.emit(constants_1.SocketEvents.CLOSE);
});
socket.on('error', (error) => {
this.emit(constants_1.SocketEvents.WARN, error);
});
this.connected = true;
const ipDiscovery = Buffer.alloc(70);
ipDiscovery.writeUIntBE(this.ssrc, 0, 4);
this.send(ipDiscovery);
return this;
}
disconnect() {
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.headers.audio.reset();
if (this.headers.video) {
this.headers.video.reset();
}
this.connected = false;
}
onPacket(packet, from) {
if (!this.receiveEnabled) {
return;
}
if (from.address !== this.remote.ip || from.port !== this.remote.port) {
const error = new errors_1.MediaPacketError('Received a packet from an unknown IP/Port', from, packet);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
if (!this.key) {
const error = new errors_1.MediaPacketError('Received a packet before the Session Description', from, packet);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
if (packet.length <= 12) {
const error = new errors_1.MediaPacketError('Received an rtp packet that\'s way too small to be valid', from, packet);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
if (!rtp_1.isValidRTPHeader(packet)) {
const error = new errors_1.MediaPacketError('Invalid RTP Packet', from, packet);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
const packetType = packet.readUIntBE(1, 1);
if (constants_1.RTCP_PACKET_TYPES.includes(packetType)) {
}
else {
const rtp = {
header: new rtp_1.RTPHeader({ buffer: packet.slice(0, 12) }),
};
let payloadType = rtp.header.payloadType;
/*
// unknown if this is how it is now
let isRTX = false;
if (payloadType === this.rtxPayloadType) {
payloadType -= 1;
isRTX = true;
}
*/
if (!constants_1.RTP_PAYLOAD_TYPES.includes(payloadType)) {
const error = new errors_1.MediaRTPError('Unknown RTP Packet Payload Type', from, packet, rtp);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
let codec = null;
let format = null;
switch (payloadType) {
case constants_1.RTPPayloadTypes.OPUS:
{
codec = constants_1.MediaCodecs.OPUS;
format = constants_1.MediaCodecTypes.AUDIO;
}
;
break;
case constants_1.RTPPayloadTypes.VP8:
{
codec = constants_1.MediaCodecs.VP8;
format = constants_1.MediaCodecTypes.VIDEO;
}
;
break;
case constants_1.RTPPayloadTypes.VP9:
{
codec = constants_1.MediaCodecs.VP9;
format = constants_1.MediaCodecTypes.VIDEO;
}
;
break;
case constants_1.RTPPayloadTypes.H264:
{
codec = constants_1.MediaCodecs.H264;
format = constants_1.MediaCodecTypes.VIDEO;
}
;
break;
}
if (format === constants_1.MediaCodecTypes.VIDEO && !this.videoEnabled) {
const error = new errors_1.MediaRTPError('Dropping video packet due to video not being enabled', from, packet, rtp);
this.emit(constants_1.SocketEvents.LOG, error);
return;
}
rtp.nonce = Buffer.alloc(24);
switch (this.mode) {
case constants_1.MediaEncryptionModes.XSALSA20_POLY1305_LITE:
{
// last 4 bytes
packet.copy(rtp.nonce, 0, packet.length - 4);
rtp.payload = packet.slice(12, -4);
}
;
break;
case constants_1.MediaEncryptionModes.XSALSA20_POLY1305_SUFFIX:
{
// last 24 bytes
packet.copy(rtp.nonce, 0, packet.length - 24);
rtp.payload = packet.slice(12, -24);
}
;
break;
case constants_1.MediaEncryptionModes.XSALSA20_POLY1305:
{
// first 12 bytes, the rtp header
// currently broken for some reason
packet.copy(rtp.nonce, 0, 0, 12);
rtp.payload = packet.slice(12);
}
;
break;
default:
{
const error = new errors_1.MediaRTPError(`${this.mode} is not supported for decoding.`, from, packet, rtp);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
;
}
let data = rtpcrypto_1.default.decrypt(this.key, rtp.payload, rtp.nonce);
if (!data) {
const error = new errors_1.MediaRTPError('Packet failed to decrypt', from, packet, rtp);
this.emit(constants_1.SocketEvents.WARN, error);
return;
}
if (rtp.header.padding) {
// RFC3550 Section 5.1
// last byte contains amount of padding, including itself, slice that stuff off
data = data.slice(0, data.length - data.readUIntBE(data.length - 1, 1));
}
if (rtp.header.extension) {
if (constants_1.RTPHeaderExtensionOneByte.HEADER.every((header, i) => header === data[i])) {
// RFC5285 Section 4.2: One-Byte Header
const fieldAmount = data.readUIntBE(2, 2);
// const fields: Array<Buffer> = [];
// Unknown as to what each field is
let offset = 4;
for (let i = 0; i < fieldAmount; i++) {
const byte = data.readUIntBE(offset++, 1);
const identifer = byte & constants_1.RTPHeaderExtensionOneByte.LOCAL_IDENTIFER;
const len = ((byte >> 4) & constants_1.RTPHeaderExtensionOneByte.LOCAL_IDENTIFER) + 1;
// ignore rest if identifier === 15 (local identifer)
if (identifer === constants_1.RTPHeaderExtensionOneByte.LOCAL_IDENTIFER) {
break;
}
// skip the field data since we don't know what to do with it
offset += len;
// fields.push(data.slice(offset, offset += len));
/*
// apparently discord's padding isn't actually padding from the RFC..
// is just padding
while (data[offset] === 0) {
offset++;
}
*/
}
// https://github.com/discordjs/discord.js/pull/3555
offset++;
data = data.slice(offset);
// do something here with the fields, then clear it
// fields.length = 0;
}
else if (constants_1.RTPHeaderExtensionTwoByte.HEADER.every((header, i) => header === data[i])) {
// RFC5285 Section 4.3: Two-Byte Header not received yet, appbits unknown anyways
// using two bytes, 0x10 and 0x00 instead
// if appbits is all 0s, ignore, so rn ignore this packet
const error = new errors_1.MediaRTPError('Received Two Byte header with appbits being 0, ignoring', from, packet, rtp);
this.emit(constants_1.SocketEvents.LOG, error);
return;
/*
// handle the two byte
const fields = [];
const fieldAmount = data.readUIntBE(2, 2);
let offset = 4;
for (let i = 0; i < fieldAmount; i++) {
const identifier = data.readUIntBE(offset++, 1);
const len = data.readUIntBE(offset++, 1);
if (!len) {continue;}
fields.push(data.slice(offset, offset + len));
offset += len;
while (data[offset] === 0) {offset++;}
}
if (offset !== data.length) {
fields.push(data.slice(offset));
//just making sure, dunno tho
}
data = (fields.length <= 1) ? fields.shift() : Buffer.concat(fields);
fields.length = 0;
*/
}
}
let userId = null;
if (format !== null) {
switch (format) {
case constants_1.MediaCodecTypes.AUDIO:
{
userId = this.mediaGateway.ssrcToUserId(rtp.header.ssrc, constants_1.MediaSSRCTypes.AUDIO);
}
;
break;
case constants_1.MediaCodecTypes.VIDEO:
{
userId = this.mediaGateway.ssrcToUserId(rtp.header.ssrc, constants_1.MediaSSRCTypes.VIDEO);
}
;
break;
}
}
this.emit(constants_1.SocketEvents.PACKET, {
codec,
data,
format,
from,
rtp,
userId,
});
}
}
send(packet) {
if (!this.connected || !this.socket) {
throw new Error('UDP is not connected yet!');
}
this.socket.send(packet, 0, packet.length, this.remote.port, this.remote.ip, (error, bytes) => {
if (error) {
this.emit(constants_1.SocketEvents.WARN, error);
}
});
}
sendAudioFrame(packet, options) {
this.sendFrame(packet, Object.assign({}, options, { type: constants_1.MediaCodecTypes.AUDIO }));
}
sendVideoFrame(packet, options) {
this.sendFrame(packet, Object.assign({}, options, { type: constants_1.MediaCodecTypes.VIDEO }));
}
sendFrame(packet, options = {}) {
if (!this.connected) {
return;
}
if (!this.key) {
throw new Error('Haven\'t received the session description yet');
}
const type = options.type;
if (type !== constants_1.MediaCodecTypes.AUDIO && type !== constants_1.MediaCodecTypes.VIDEO) {
throw new Error('Invalid frame type');
}
let useCache = options.useCache || options.useCache === undefined;
if (type === constants_1.MediaCodecTypes.VIDEO && !this.videoEnabled) {
throw new Error('Cannot send in video frames when video is disabled!');
}
const cache = {};
switch (type) {
case constants_1.MediaCodecTypes.AUDIO:
{
cache.header = this.headers.audio;
cache.nonce = this.nonces.audio;
cache.payload = this.caches.audio;
}
;
break;
case constants_1.MediaCodecTypes.VIDEO:
{
cache.header = this.headers.video;
cache.nonce = this.nonces.video;
cache.payload = this.caches.video;
}
;
break;
default:
{
throw new Error(`Invalid type ${type}`);
}
;
}
cache.header = cache.header;
cache.nonce = cache.nonce;
cache.payload = cache.payload;
const rtp = {};
if (useCache) {
rtp.header = cache.header;
rtp.nonce = cache.nonce;
}
else {
let payloadType, ssrc;
switch (type) {
case constants_1.MediaCodecTypes.AUDIO:
{
payloadType = this.rtpAudioPayloadType;
ssrc = this.audioSSRC;
}
;
break;
case constants_1.MediaCodecTypes.VIDEO:
{
payloadType = this.rtpVideoPayloadType;
ssrc = this.videoSSRC;
}
;
break;
default:
{
throw new Error(`Invalid type ${type}`);
}
;
}
rtp.header = new rtp_1.RTPHeader({ payloadType, ssrc });
rtp.nonce = new rtp_1.RTPNonce({ randomize: true });
}
rtp.header = rtp.header;
rtp.nonce = rtp.nonce;
if (!useCache && cache.header) {
if (options.sequence === undefined) {
options.sequence = cache.header.sequence;
options.incrementSequence = false;
}
if (options.timestamp === undefined) {
options.timestamp = cache.header.timestamp;
options.incrementTimestamp = false;
}
}
rtp.header.setSequence(options.sequence, options.incrementSequence);
rtp.header.setTimestamp(options.timestamp, options.incrementTimestamp);
const data = [];
const payloadDataCache = (useCache) ? cache.payload.slice(12) : null;
let nonce;
switch (this.mode) {
case constants_1.MediaEncryptionModes.XSALSA20_POLY1305_LITE:
{
nonce = rtp.nonce.set(options.nonce, options.incrementNonce);
data.push(nonce.slice(0, 4));
}
;
break;
case constants_1.MediaEncryptionModes.XSALSA20_POLY1305_SUFFIX:
{
nonce = rtp.nonce.generate();
data.push(nonce);
}
;
break;
case constants_1.MediaEncryptionModes.XSALSA20_POLY1305:
{
rtp.header.copy(rtp.nonce.buffer);
nonce = rtp.nonce.buffer;
}
;
break;
default:
{
throw new Error(`${this.mode} is not supported for encoding.`);
}
;
}
data.unshift(rtpcrypto_1.default.encrypt(this.key, packet, nonce, payloadDataCache));
let buffer;
if (useCache) {
let total = rtp.header.length;
rtp.header.copy(cache.payload);
data.forEach((buf) => {
const start = total;
total += buf.length;
if (buf instanceof Buffer) {
buf.copy(cache.payload, start);
}
});
buffer = cache.payload.slice(0, total);
}
else {
const buffers = [rtp.header.buffer, ...data].map((buffer) => {
if (buffer instanceof Buffer) {
return buffer;
}
return buffer.packet;
});
buffer = Buffer.concat(buffers);
}
this.send(buffer);
}
sendAudioSilenceFrame() {
this.sendFrame(Buffer.from(constants_1.MediaSilencePacket), {
incrementTimestamp: true,
timestamp: 960,
type: constants_1.MediaCodecTypes.AUDIO,
});
}
on(event, listener) {
super.on(event, listener);
return this;
}
}
exports.Socket = Socket;