rockcord 4.0.0-dev.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +17 -14
- package/src/client/Client.js +3 -3
- package/src/client/WebhookClient.js +1 -1
- package/src/client/actions/VoiceStateUpdate.js +2 -2
- package/src/client/voice/VoiceConnection.js +98 -24
- package/src/client/voice/dispatcher/AudioDispatcher.js +1 -1
- package/src/client/voice/dispatcher/BaseDispatcher.js +49 -61
- package/src/client/voice/dispatcher/VideoDispatcher.js +86 -2
- package/src/client/voice/networking/CongestionControl.js +84 -0
- package/src/client/voice/networking/DAVE.js +26 -1
- package/src/client/voice/networking/MediaRecovery.js +300 -0
- package/src/client/voice/networking/VoiceUDPClient.js +10 -1
- package/src/client/voice/networking/VoiceWebSocket.js +4 -1
- package/src/client/voice/player/MediaPlayer.js +45 -11
- package/src/client/voice/player/processing/AnnexBNalSplitter.js +0 -1
- package/src/client/voice/receiver/AudioJitter.js +101 -0
- package/src/client/voice/receiver/PacketHandler.js +66 -9
- package/src/client/voice/receiver/Receiver.js +9 -4
- package/src/client/voice/receiver/Recorder.js +186 -57
- package/src/client/voice/util/PlayInterface.js +2 -0
- package/src/client/voice/util/Secretbox.js +1 -1
- package/src/client/voice/util/TransportCrypto.js +54 -0
- package/src/client/voice/util/VideoFrames.js +4 -1
- package/src/client/websocket/WebSocketShard.js +0 -1
- package/src/managers/ApplicationCommandManager.js +0 -2
- package/src/managers/ApplicationCommandPermissionsManager.js +0 -1
- package/src/managers/GuildChannelManager.js +2 -3
- package/src/managers/GuildMemberManager.js +0 -1
- package/src/managers/GuildTextThreadManager.js +1 -1
- package/src/managers/MessageManager.js +5 -5
- package/src/managers/RoleManager.js +1 -1
- package/src/rest/APIRequest.js +0 -1
- package/src/rest/APIRouter.js +2 -2
- package/src/rest/DiscordAPIError.js +1 -1
- package/src/rest/RequestHandler.js +3 -3
- package/src/sharding/ShardingManager.js +2 -2
- package/src/structures/ApplicationCommand.js +2 -7
- package/src/structures/BaseCommandInteraction.js +1 -2
- package/src/structures/BaseGuildTextChannel.js +1 -1
- package/src/structures/BaseGuildVoiceChannel.js +1 -1
- package/src/structures/ClientPresence.js +0 -1
- package/src/structures/ClientUser.js +13 -5
- package/src/structures/ContainerComponent.js +1 -1
- package/src/structures/DMChannel.js +1 -1
- package/src/structures/GroupDMChannel.js +4 -4
- package/src/structures/Guild.js +0 -2
- package/src/structures/GuildAuditLogs.js +4 -4
- package/src/structures/GuildScheduledEvent.js +0 -2
- package/src/structures/Interaction.js +1 -1
- package/src/structures/InteractionCollector.js +2 -2
- package/src/structures/InteractionWebhook.js +0 -1
- package/src/structures/Message.js +16 -17
- package/src/structures/MessageComponentInteraction.js +1 -1
- package/src/structures/MessageEmbed.js +0 -2
- package/src/structures/MessagePayload.js +2 -2
- package/src/structures/ModalSubmitInteraction.js +2 -2
- package/src/structures/ThreadChannel.js +3 -3
- package/src/structures/ThreadOnlyChannel.js +1 -1
- package/src/structures/WebEmbed.js +0 -1
- package/src/structures/interfaces/Application.js +2 -2
- package/src/structures/interfaces/Collector.js +1 -4
- package/src/structures/interfaces/TextBasedChannel.js +6 -6
- package/src/util/AttachmentFlags.js +0 -1
- package/src/util/Constants.js +0 -2
- package/src/util/Options.js +3 -3
- package/src/util/Util.js +1 -2
- package/typings/index.d.ts +109 -115
- package/typings/rawDataTypes.d.ts +5 -15
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
npm i rockcord
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Requires **Node.js 22 or newer**; tested on Node.js 22 and 24. Import with `require('rockcord')` or TypeScript imports from `'rockcord'`.
|
|
22
|
+
Requires **Node.js 22.19.0 or newer**; tested on Node.js 22 and 24. Import with `require('rockcord')` or TypeScript imports from `'rockcord'`.
|
|
23
23
|
|
|
24
24
|
Media playback also requires FFmpeg and a supported Opus binding. Keep optional dependencies enabled so npm can install DAVE's native binary for your platform.
|
|
25
25
|
|
|
@@ -30,8 +30,10 @@ Media playback also requires FFmpeg and a supported Opus binding. Keep optional
|
|
|
30
30
|
- Components V2 support and checked runtime exports with TypeScript declarations.
|
|
31
31
|
- Bounded REST retries, request deadlines, credential redaction, and connection cleanup.
|
|
32
32
|
- Native DAVE/MLS encryption for audio and video, complete received video frames, and voice privacy verification codes.
|
|
33
|
+
- Screen-share connections and H264, VP8, and H265 playback and recording with Opus audio.
|
|
34
|
+
- Adaptive video packet pacing using authenticated receiver loss reports, bounded retransmission, and buffered audio reception.
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
The **4.0.0 stable release** includes the media repairs and reviewed dependency updates. See the [media options](https://github.com/rock-forge/rockcord/blob/main/docs/MEDIA.md), [dependency review](https://github.com/rock-forge/rockcord/blob/main/docs/audit/DEPENDENCY_REVIEW.md), and [implementation report](https://github.com/rock-forge/rockcord/blob/main/docs/audit/IMPLEMENTATION.md) for configuration and verification coverage.
|
|
35
37
|
|
|
36
38
|
## Project links
|
|
37
39
|
|
|
@@ -43,6 +45,6 @@ This is a **development release**. Live audio, H264 camera video, rejoining, and
|
|
|
43
45
|
|
|
44
46
|
## Credits and license
|
|
45
47
|
|
|
46
|
-
Rockcord is maintained independently by [Rock Forge](https://github.com/rock-forge) and [devrock07](https://github.com/devrock07). It builds on [discord.js-selfbot-v13](https://github.com/aiko-chan-ai/discord.js-selfbot-v13) and [discord.js](https://github.com/discordjs/discord.js)
|
|
48
|
+
Rockcord is maintained independently by [Rock Forge](https://github.com/rock-forge) and [devrock07](https://github.com/devrock07). It builds on [discord.js-selfbot-v13](https://github.com/aiko-chan-ai/discord.js-selfbot-v13) and [discord.js](https://github.com/discordjs/discord.js). Original source attribution and licensing are preserved; this repository starts with fresh Rock Forge commit history.
|
|
47
49
|
|
|
48
50
|
Licensed under [GPL v3](https://github.com/rock-forge/rockcord/blob/main/LICENSE). Dependencies retain their own licenses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rockcord",
|
|
3
|
-
"version": "4.0.0
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Rockcord: a maintained Discord client library with tested v13 APIs and DAVE media encryption",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./typings/index.d.ts",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"all": "npm run build",
|
|
9
9
|
"test": "npm run test:unit && npm run lint:all && npm run docs:test && npm run test:typescript",
|
|
10
10
|
"test:unit": "node --test test/*.test.cjs",
|
|
11
|
-
"test:media": "node scripts/media-integration.cjs",
|
|
11
|
+
"test:media": "node scripts/media-integration.cjs && node scripts/recording-integration.cjs",
|
|
12
12
|
"test:live": "node --env-file=.env scripts/live-voice-check.cjs",
|
|
13
13
|
"bench": "node --expose-gc scripts/bench.cjs",
|
|
14
14
|
"fix:all": "npm run lint:fix && npm run lint:typings:fix && npm run format",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"client",
|
|
50
50
|
"discordapp"
|
|
51
51
|
],
|
|
52
|
-
"author": "
|
|
52
|
+
"author": "Rock Forge (https://github.com/rock-forge)",
|
|
53
53
|
"license": "GNU General Public License v3.0",
|
|
54
54
|
"bugs": {
|
|
55
55
|
"url": "https://github.com/rock-forge/rockcord/issues"
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@discordjs/builders": "^1.6.3",
|
|
60
60
|
"@discordjs/collection": "^2.1.1",
|
|
61
|
+
"@otplib/v12-adapter": "^13.5.0",
|
|
61
62
|
"@sapphire/async-queue": "^1.5.5",
|
|
62
63
|
"@sapphire/shapeshift": "^4.0.0",
|
|
63
64
|
"@snazzah/davey": "0.1.12",
|
|
@@ -66,29 +67,31 @@
|
|
|
66
67
|
"discord-api-types": "^0.38.15",
|
|
67
68
|
"fetch-cookie": "^3.1.0",
|
|
68
69
|
"find-process": "^2.0.0",
|
|
69
|
-
"otplib": "^12.0.1",
|
|
70
70
|
"prism-media": "^1.3.5",
|
|
71
71
|
"qrcode": "^1.5.4",
|
|
72
|
-
"tough-cookie": "^
|
|
72
|
+
"tough-cookie": "^6.0.2",
|
|
73
73
|
"tree-kill": "^1.2.2",
|
|
74
|
-
"undici": "^
|
|
74
|
+
"undici": "^8.11.2",
|
|
75
75
|
"werift-rtp": "^0.8.4",
|
|
76
76
|
"ws": "^8.16.0"
|
|
77
77
|
},
|
|
78
78
|
"engines": {
|
|
79
|
-
"node": ">=22.
|
|
79
|
+
"node": ">=22.19.0"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@discordjs/docgen": "^0.12.1",
|
|
83
|
+
"@eslint/js": "^10.0.1",
|
|
83
84
|
"@types/debug": "^4.1.12",
|
|
84
|
-
"eslint": "^
|
|
85
|
-
"eslint-config-prettier": "^
|
|
86
|
-
"eslint-
|
|
87
|
-
"eslint-plugin-
|
|
88
|
-
"
|
|
85
|
+
"eslint": "^10.12.0",
|
|
86
|
+
"eslint-config-prettier": "^10.1.8",
|
|
87
|
+
"eslint-import-resolver-node": "^0.3.10",
|
|
88
|
+
"eslint-plugin-import-x": "^4.17.1",
|
|
89
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
90
|
+
"globals": "^16.5.0",
|
|
91
|
+
"jsdoc-to-markdown": "^8.0.3",
|
|
89
92
|
"opusscript": "0.0.8",
|
|
90
|
-
"prettier": "^
|
|
91
|
-
"typescript": "^5.
|
|
93
|
+
"prettier": "^3.9.9",
|
|
94
|
+
"typescript": "^5.9.3"
|
|
92
95
|
},
|
|
93
96
|
"overrides": {
|
|
94
97
|
"jsdoc": "^4.0.5"
|
package/src/client/Client.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
/* eslint-disable no-unreachable */
|
|
2
1
|
'use strict';
|
|
3
2
|
|
|
4
3
|
const process = require('node:process');
|
|
5
4
|
const { setInterval } = require('node:timers');
|
|
6
5
|
const { setTimeout } = require('node:timers');
|
|
7
6
|
const { Collection } = require('@discordjs/collection');
|
|
8
|
-
const { authenticator } = require('otplib');
|
|
7
|
+
const { authenticator } = require('@otplib/v12-adapter');
|
|
9
8
|
const BaseClient = require('./BaseClient');
|
|
10
9
|
const ActionsManager = require('./actions/ActionsManager');
|
|
11
10
|
const ClientVoiceManager = require('./voice/ClientVoiceManager');
|
|
@@ -205,7 +204,7 @@ class Client extends BaseClient {
|
|
|
205
204
|
* The authenticator used for TOTP
|
|
206
205
|
* @type {Object}
|
|
207
206
|
*/
|
|
208
|
-
this.authenticator = authenticator;
|
|
207
|
+
this.authenticator = authenticator.create({ guardrails: { MIN_SECRET_BYTES: 1 } });
|
|
209
208
|
|
|
210
209
|
this.authenticator.options = {
|
|
211
210
|
step: 30,
|
|
@@ -352,6 +351,7 @@ class Client extends BaseClient {
|
|
|
352
351
|
* @returns {void}
|
|
353
352
|
*/
|
|
354
353
|
destroy() {
|
|
354
|
+
this.user?._stopSamsungPresence?.();
|
|
355
355
|
super.destroy();
|
|
356
356
|
|
|
357
357
|
for (const fn of this._cleanups) fn();
|
|
@@ -16,11 +16,11 @@ class VoiceStateUpdate extends Action {
|
|
|
16
16
|
const newState = guild.voiceStates._add(data);
|
|
17
17
|
|
|
18
18
|
// Get the member
|
|
19
|
-
|
|
19
|
+
const member = guild.members.cache.get(data.user_id);
|
|
20
20
|
if (member && data.member) {
|
|
21
21
|
member._patch(data.member);
|
|
22
22
|
} else if (data.member?.user && data.member.joined_at) {
|
|
23
|
-
|
|
23
|
+
guild.members._add(data.member);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -31,7 +31,7 @@ if (getCiphers().includes('aes-256-gcm')) {
|
|
|
31
31
|
SUPPORTED_MODES.unshift('aead_aes256_gcm_rtpsize');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const SUPPORTED_CODECS = ['VP8', 'H264'];
|
|
34
|
+
const SUPPORTED_CODECS = ['VP8', 'H264', 'H265'];
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Represents a connection to a guild's voice server.
|
|
@@ -144,7 +144,7 @@ class VoiceConnection extends EventEmitter {
|
|
|
144
144
|
* * `VP8`
|
|
145
145
|
* * `VP9` (Not supported for encoding & decoding)
|
|
146
146
|
* * `H264`
|
|
147
|
-
* * `H265`
|
|
147
|
+
* * `H265`
|
|
148
148
|
* * `AV1` (Not supported for encoding & decoding)
|
|
149
149
|
* @typedef {string} VideoCodec
|
|
150
150
|
*/
|
|
@@ -312,10 +312,36 @@ class VoiceConnection extends EventEmitter {
|
|
|
312
312
|
|
|
313
313
|
this.emit('debug', `Sending voice state update: ${JSON.stringify(options)}`);
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
315
|
+
this._pendingVoiceState?.cancel();
|
|
316
|
+
let settle;
|
|
317
|
+
const pending = { promise: new Promise(resolve => (settle = resolve)) };
|
|
318
|
+
const finish = acknowledged => {
|
|
319
|
+
clearTimeout(pending.timer);
|
|
320
|
+
this.client.removeListener('raw', onState);
|
|
321
|
+
if (this._pendingVoiceState === pending) this._pendingVoiceState = null;
|
|
322
|
+
settle(acknowledged);
|
|
323
|
+
};
|
|
324
|
+
const onState = packet => {
|
|
325
|
+
if (packet?.t !== 'VOICE_STATE_UPDATE' || packet.d?.user_id !== this.client.user?.id) return;
|
|
326
|
+
if (!['channel_id', 'self_mute', 'self_deaf', 'self_video'].every(key => packet.d[key] === options[key])) return;
|
|
327
|
+
finish(true);
|
|
328
|
+
};
|
|
329
|
+
pending.cancel = () => finish(false);
|
|
330
|
+
pending.timer = setTimeout(pending.cancel, 15_000).unref();
|
|
331
|
+
this._pendingVoiceState = pending;
|
|
332
|
+
this.client.on('raw', onState);
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
return Promise.resolve(this.channel.client.ws.broadcast({ op: Opcodes.VOICE_STATE_UPDATE, d: options })).catch(
|
|
336
|
+
error => {
|
|
337
|
+
pending.cancel();
|
|
338
|
+
throw error;
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
pending.cancel();
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
319
345
|
}
|
|
320
346
|
|
|
321
347
|
/**
|
|
@@ -498,6 +524,11 @@ class VoiceConnection extends EventEmitter {
|
|
|
498
524
|
* @private
|
|
499
525
|
*/
|
|
500
526
|
cleanup() {
|
|
527
|
+
this._pendingVoiceState?.cancel();
|
|
528
|
+
if (!this.voiceConnection) {
|
|
529
|
+
this.streamConnection?.disconnect();
|
|
530
|
+
for (const stream of this.streamWatchConnection.values()) stream.disconnect();
|
|
531
|
+
}
|
|
501
532
|
this.player.destroy();
|
|
502
533
|
this.speaking = new Speaking().freeze();
|
|
503
534
|
const { ws, udp } = this.sockets;
|
|
@@ -576,6 +607,9 @@ class VoiceConnection extends EventEmitter {
|
|
|
576
607
|
* @private
|
|
577
608
|
*/
|
|
578
609
|
onSessionDescription(data) {
|
|
610
|
+
this._mediaRecovery?.reset();
|
|
611
|
+
for (const jitter of this.receiver.packets.audioJitters.values()) jitter.reset();
|
|
612
|
+
this.receiver.packets.audioJitters.clear();
|
|
579
613
|
Object.assign(this.authentication, data);
|
|
580
614
|
this.status = VoiceStatus.CONNECTED;
|
|
581
615
|
const ready = () => {
|
|
@@ -606,17 +640,21 @@ class VoiceConnection extends EventEmitter {
|
|
|
606
640
|
}
|
|
607
641
|
|
|
608
642
|
onStartStreaming({ video_ssrc, user_id, audio_ssrc, rtx_ssrc, streams = [] }) {
|
|
643
|
+
const layers = streams.length
|
|
644
|
+
? streams.filter(stream => stream.active !== false)
|
|
645
|
+
: [{ ssrc: video_ssrc, rtx_ssrc }];
|
|
646
|
+
const retained = new Set(layers.flatMap(layer => [+layer.ssrc, +layer.rtx_ssrc]).filter(Boolean));
|
|
609
647
|
for (const [ssrc, info] of this.ssrcMap) {
|
|
610
|
-
if (info.userId === user_id && ['video', 'video-rtx'].includes(info.kind)
|
|
648
|
+
if (info.userId === user_id && ['video', 'video-rtx'].includes(info.kind) && !retained.has(ssrc)) {
|
|
649
|
+
this.ssrcMap.delete(ssrc);
|
|
650
|
+
this.receiver.packets.forgetSource(ssrc);
|
|
651
|
+
}
|
|
611
652
|
}
|
|
612
653
|
this.ssrcMap.set(+audio_ssrc, {
|
|
613
654
|
...(this.ssrcMap.get(+audio_ssrc) || {}),
|
|
614
655
|
userId: user_id,
|
|
615
656
|
hasVideo: Boolean(video_ssrc), // Maybe ?
|
|
616
657
|
});
|
|
617
|
-
const layers = streams.length
|
|
618
|
-
? streams.filter(stream => stream.active !== false)
|
|
619
|
-
: [{ ssrc: video_ssrc, rtx_ssrc }];
|
|
620
658
|
for (const layer of layers) {
|
|
621
659
|
if (!layer.ssrc) continue;
|
|
622
660
|
this.ssrcMap.set(+layer.ssrc, { userId: user_id, hasVideo: true, kind: 'video' });
|
|
@@ -688,20 +726,23 @@ class VoiceConnection extends EventEmitter {
|
|
|
688
726
|
}
|
|
689
727
|
}
|
|
690
728
|
|
|
691
|
-
playAudio() {}
|
|
692
|
-
playVideo() {}
|
|
729
|
+
playAudio() {}
|
|
730
|
+
playVideo() {}
|
|
693
731
|
|
|
694
732
|
/**
|
|
695
733
|
* Create new connection to screenshare stream
|
|
696
734
|
* @returns {Promise<StreamConnection>}
|
|
697
735
|
*/
|
|
698
736
|
createStreamConnection() {
|
|
737
|
+
if (this.streamConnection?._readyPromise) return this.streamConnection._readyPromise;
|
|
738
|
+
let created;
|
|
699
739
|
// eslint-disable-next-line consistent-return
|
|
700
|
-
|
|
740
|
+
const pending = new Promise((resolve, reject) => {
|
|
701
741
|
if (this.streamConnection) {
|
|
702
742
|
return resolve(this.streamConnection);
|
|
703
743
|
} else {
|
|
704
744
|
const connection = (this.streamConnection = new StreamConnection(this.voiceManager, this.channel, this));
|
|
745
|
+
created = connection;
|
|
705
746
|
connection.setVideoCodec(this.videoCodec); // Sync :?
|
|
706
747
|
// Setup event...
|
|
707
748
|
if (!this.eventHook) {
|
|
@@ -720,8 +761,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
720
761
|
// Current user stream
|
|
721
762
|
switch (event) {
|
|
722
763
|
case 'STREAM_CREATE': {
|
|
723
|
-
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
724
764
|
this.streamConnection.serverId = data.rtc_server_id;
|
|
765
|
+
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
725
766
|
break;
|
|
726
767
|
}
|
|
727
768
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -743,8 +784,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
743
784
|
// Watch user stream
|
|
744
785
|
switch (event) {
|
|
745
786
|
case 'STREAM_CREATE': {
|
|
746
|
-
streamConnection.setSessionId(this.authentication.sessionId);
|
|
747
787
|
streamConnection.serverId = data.rtc_server_id;
|
|
788
|
+
streamConnection.setSessionId(this.authentication.sessionId);
|
|
748
789
|
break;
|
|
749
790
|
}
|
|
750
791
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -782,12 +823,16 @@ class VoiceConnection extends EventEmitter {
|
|
|
782
823
|
connection.disconnect();
|
|
783
824
|
});
|
|
784
825
|
|
|
785
|
-
|
|
826
|
+
const onError = error => {
|
|
827
|
+
reject(error);
|
|
828
|
+
connection.disconnect();
|
|
829
|
+
};
|
|
830
|
+
connection.on('error', onError);
|
|
786
831
|
|
|
787
832
|
connection.once('ready', () => {
|
|
788
833
|
clearTimeout(connection.connectTimeout);
|
|
789
834
|
resolve(connection);
|
|
790
|
-
connection.removeListener('error',
|
|
835
|
+
connection.removeListener('error', onError);
|
|
791
836
|
});
|
|
792
837
|
connection.once('disconnect', () => {
|
|
793
838
|
clearTimeout(connection.connectTimeout);
|
|
@@ -798,10 +843,28 @@ class VoiceConnection extends EventEmitter {
|
|
|
798
843
|
() => connection.authenticateFailed('VOICE_CONNECTION_TIMEOUT'),
|
|
799
844
|
15_000,
|
|
800
845
|
).unref();
|
|
801
|
-
|
|
802
|
-
|
|
846
|
+
const start = () => {
|
|
847
|
+
if (connection.status === VoiceStatus.DISCONNECTED) return;
|
|
848
|
+
connection.sendSignalScreenshare();
|
|
849
|
+
connection.sendScreenshareState(false);
|
|
850
|
+
};
|
|
851
|
+
// Wait for an in-flight camera/voice change before creating a stream: a late
|
|
852
|
+
// camera-off update can otherwise cancel the newly created stream server-side.
|
|
853
|
+
if (this._pendingVoiceState) {
|
|
854
|
+
this._pendingVoiceState.promise.then(acknowledged => {
|
|
855
|
+
if (acknowledged) {
|
|
856
|
+
start();
|
|
857
|
+
} else if (connection.status !== VoiceStatus.DISCONNECTED) {
|
|
858
|
+
connection.authenticateFailed('VOICE_CONNECTION_TIMEOUT');
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
} else {
|
|
862
|
+
start();
|
|
863
|
+
}
|
|
803
864
|
}
|
|
804
865
|
});
|
|
866
|
+
if (created) created._readyPromise = pending;
|
|
867
|
+
return pending;
|
|
805
868
|
}
|
|
806
869
|
|
|
807
870
|
/**
|
|
@@ -819,12 +882,17 @@ class VoiceConnection extends EventEmitter {
|
|
|
819
882
|
if (!voiceState || !voiceState.streaming) {
|
|
820
883
|
throw new Error('VOICE_USER_NOT_STREAMING');
|
|
821
884
|
}
|
|
885
|
+
if (this.streamWatchConnection.get(userId)?._readyPromise) {
|
|
886
|
+
return this.streamWatchConnection.get(userId)._readyPromise;
|
|
887
|
+
}
|
|
888
|
+
let created;
|
|
822
889
|
// eslint-disable-next-line consistent-return
|
|
823
|
-
|
|
890
|
+
const pending = new Promise((resolve, reject) => {
|
|
824
891
|
if (this.streamWatchConnection.has(userId)) {
|
|
825
892
|
return resolve(this.streamWatchConnection.get(userId));
|
|
826
893
|
} else {
|
|
827
894
|
const connection = new StreamConnectionReadonly(this.voiceManager, this.channel, this, userId);
|
|
895
|
+
created = connection;
|
|
828
896
|
this.streamWatchConnection.set(userId, connection);
|
|
829
897
|
connection.setVideoCodec(this.videoCodec);
|
|
830
898
|
// Setup event...
|
|
@@ -844,8 +912,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
844
912
|
// Current user stream
|
|
845
913
|
switch (event) {
|
|
846
914
|
case 'STREAM_CREATE': {
|
|
847
|
-
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
848
915
|
this.streamConnection.serverId = data.rtc_server_id;
|
|
916
|
+
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
849
917
|
break;
|
|
850
918
|
}
|
|
851
919
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -867,8 +935,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
867
935
|
// Watch user stream
|
|
868
936
|
switch (event) {
|
|
869
937
|
case 'STREAM_CREATE': {
|
|
870
|
-
streamConnection.setSessionId(this.authentication.sessionId);
|
|
871
938
|
streamConnection.serverId = data.rtc_server_id;
|
|
939
|
+
streamConnection.setSessionId(this.authentication.sessionId);
|
|
872
940
|
break;
|
|
873
941
|
}
|
|
874
942
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -908,12 +976,16 @@ class VoiceConnection extends EventEmitter {
|
|
|
908
976
|
connection.disconnect();
|
|
909
977
|
});
|
|
910
978
|
|
|
911
|
-
|
|
979
|
+
const onError = error => {
|
|
980
|
+
reject(error);
|
|
981
|
+
connection.disconnect();
|
|
982
|
+
};
|
|
983
|
+
connection.on('error', onError);
|
|
912
984
|
|
|
913
985
|
connection.once('ready', () => {
|
|
914
986
|
clearTimeout(connection.connectTimeout);
|
|
915
987
|
resolve(connection);
|
|
916
|
-
connection.removeListener('error',
|
|
988
|
+
connection.removeListener('error', onError);
|
|
917
989
|
});
|
|
918
990
|
connection.once('disconnect', () => {
|
|
919
991
|
clearTimeout(connection.connectTimeout);
|
|
@@ -927,6 +999,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
927
999
|
connection.sendSignalScreenshare();
|
|
928
1000
|
}
|
|
929
1001
|
});
|
|
1002
|
+
if (created) created._readyPromise = pending;
|
|
1003
|
+
return pending;
|
|
930
1004
|
}
|
|
931
1005
|
|
|
932
1006
|
/**
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const crypto = require('node:crypto');
|
|
5
4
|
const { Writable } = require('node:stream');
|
|
6
5
|
const { setTimeout } = require('node:timers');
|
|
7
|
-
const
|
|
6
|
+
const { encrypt } = require('../util/TransportCrypto');
|
|
8
7
|
|
|
9
8
|
const MAX_UINT_16 = 2 ** 16 - 1;
|
|
10
9
|
const MAX_UINT_32 = 2 ** 32 - 1;
|
|
11
10
|
|
|
12
|
-
const nonceStates = new WeakMap();
|
|
13
11
|
const packetStates = new WeakMap();
|
|
14
12
|
|
|
15
13
|
/**
|
|
@@ -86,9 +84,12 @@ class BaseDispatcher extends Writable {
|
|
|
86
84
|
|
|
87
85
|
this.on('finish', () => {
|
|
88
86
|
this._cleanup();
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
this.
|
|
87
|
+
if (this.getTypeDispatcher() === 'audio') {
|
|
88
|
+
this._setSpeaking(0);
|
|
89
|
+
} else if (this.getTypeDispatcher() === 'video') {
|
|
90
|
+
this._setVideoStatus(false);
|
|
91
|
+
this._setStreamStatus(true);
|
|
92
|
+
}
|
|
92
93
|
});
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -132,6 +133,19 @@ class BaseDispatcher extends Writable {
|
|
|
132
133
|
this._syncDispatcher.once('start', cb);
|
|
133
134
|
this._syncStartTimer = setTimeout(cb, 10_000).unref();
|
|
134
135
|
}
|
|
136
|
+
// Announce both dispatchers before arbitrating the initial encrypted frame,
|
|
137
|
+
// so audio/video synchronization cannot deadlock against startup ownership.
|
|
138
|
+
const dave = this.player.voiceConnection?.dave;
|
|
139
|
+
if (dave?.beginMedia?.(this) === false) {
|
|
140
|
+
this._bootstrapWaitAt ??= performance.now();
|
|
141
|
+
if (performance.now() - this._bootstrapWaitAt > 10000) {
|
|
142
|
+
done(new Error('DAVE media bootstrap timed out'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this._bootstrapTimer = setTimeout(() => this._write(chunk, enc, done), 5).unref();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this._bootstrapWaitAt = null;
|
|
135
149
|
try {
|
|
136
150
|
if (this.getTypeDispatcher() === 'video') {
|
|
137
151
|
this._codecCallback(chunk);
|
|
@@ -151,6 +165,8 @@ class BaseDispatcher extends Writable {
|
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
_cleanup() {
|
|
168
|
+
clearTimeout(this._bootstrapTimer);
|
|
169
|
+
this.player.voiceConnection?.dave?.releaseMedia?.(this);
|
|
154
170
|
clearTimeout(this._stepTimer);
|
|
155
171
|
clearTimeout(this._syncStartTimer);
|
|
156
172
|
if (this._syncStartListener) this._syncDispatcher?.removeListener('start', this._syncStartListener);
|
|
@@ -245,9 +261,12 @@ class BaseDispatcher extends Writable {
|
|
|
245
261
|
done();
|
|
246
262
|
};
|
|
247
263
|
const next = (this.count + 1) * this.FRAME_LENGTH - (performance.now() - this.startTime - this._pausedTime);
|
|
248
|
-
this._stepTimer = setTimeout(
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
this._stepTimer = setTimeout(
|
|
265
|
+
() => {
|
|
266
|
+
if ((!this.pausedSince || this._silence) && this._writeCallback) this._writeCallback();
|
|
267
|
+
},
|
|
268
|
+
Math.max(0, next),
|
|
269
|
+
).unref();
|
|
251
270
|
this.timestamp = (this.timestamp + this.TIMESTAMP_INC) % (MAX_UINT_32 + 1);
|
|
252
271
|
if (this._packetState) this._packetState.timestamp = this.timestamp;
|
|
253
272
|
this.count++;
|
|
@@ -313,50 +332,11 @@ class BaseDispatcher extends Writable {
|
|
|
313
332
|
}
|
|
314
333
|
|
|
315
334
|
_encrypt(buffer, additionalData) {
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
const connection = this.player.voiceConnection;
|
|
319
|
-
let state = nonceStates.get(connection);
|
|
320
|
-
if (!state || !state.key.equals(Buffer.from(secret_key))) {
|
|
321
|
-
state = { key: Buffer.from(secret_key), value: 0 };
|
|
322
|
-
nonceStates.set(connection, state);
|
|
323
|
-
}
|
|
324
|
-
if (state.value >= MAX_UINT_32) {
|
|
325
|
-
throw new RangeError('Voice encryption nonce exhausted; establish a new session key');
|
|
326
|
-
}
|
|
327
|
-
this._nonce = ++state.value;
|
|
335
|
+
const parts = encrypt(this.player.voiceConnection, buffer, additionalData);
|
|
336
|
+
this._nonce = parts[1].readUInt32BE();
|
|
328
337
|
this.resetNonceBuffer();
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// 4 extra bytes of padding on the end of the encrypted packet
|
|
332
|
-
const noncePadding = this._nonceBuffer.slice(0, 4);
|
|
333
|
-
|
|
334
|
-
let encrypted;
|
|
335
|
-
|
|
336
|
-
switch (mode) {
|
|
337
|
-
case 'aead_aes256_gcm_rtpsize': {
|
|
338
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', secret_key, this._nonceBuffer);
|
|
339
|
-
cipher.setAAD(additionalData);
|
|
340
|
-
|
|
341
|
-
encrypted = Buffer.concat([cipher.update(buffer), cipher.final(), cipher.getAuthTag()]);
|
|
342
|
-
|
|
343
|
-
return [encrypted, noncePadding];
|
|
344
|
-
}
|
|
345
|
-
case 'aead_xchacha20_poly1305_rtpsize': {
|
|
346
|
-
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
347
|
-
buffer,
|
|
348
|
-
additionalData,
|
|
349
|
-
this._nonceBuffer,
|
|
350
|
-
secret_key,
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
return [encrypted, noncePadding];
|
|
354
|
-
}
|
|
355
|
-
default: {
|
|
356
|
-
// This should never happen. Our encryption mode is chosen from a list given to us by the gateway and checked with the ones we support.
|
|
357
|
-
throw new RangeError(`Unsupported encryption method: ${mode}`);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
338
|
+
parts[1].copy(this._nonceBuffer);
|
|
339
|
+
return parts;
|
|
360
340
|
}
|
|
361
341
|
|
|
362
342
|
_createPacket(buffer, isLastPacket) {
|
|
@@ -403,6 +383,7 @@ class BaseDispatcher extends Writable {
|
|
|
403
383
|
8,
|
|
404
384
|
4,
|
|
405
385
|
);
|
|
386
|
+
this.player.voiceConnection._mediaRecovery?.remember(rtpHeader, buffer);
|
|
406
387
|
return Buffer.concat([rtpHeader, ...this._encrypt(buffer, rtpHeader)]);
|
|
407
388
|
}
|
|
408
389
|
|
|
@@ -422,15 +403,22 @@ class BaseDispatcher extends Writable {
|
|
|
422
403
|
this.emit('debug', 'Failed to send a packet - no UDP socket');
|
|
423
404
|
return;
|
|
424
405
|
}
|
|
425
|
-
this.player.voiceConnection.sockets.udp
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
406
|
+
this.player.voiceConnection.sockets.udp
|
|
407
|
+
.send(packet)
|
|
408
|
+
.then(() => {
|
|
409
|
+
if (this.getTypeDispatcher() === 'audio' || packet[1] & 0x80) {
|
|
410
|
+
this.player.voiceConnection.dave?.sentMedia?.(this);
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
.catch(e => {
|
|
414
|
+
if (this.getTypeDispatcher() === 'audio') {
|
|
415
|
+
this._setSpeaking(0);
|
|
416
|
+
} else if (this.getTypeDispatcher() === 'video') {
|
|
417
|
+
this._setVideoStatus(false);
|
|
418
|
+
this._setStreamStatus(true);
|
|
419
|
+
}
|
|
420
|
+
this.emit('debug', `Failed to send a packet - ${e}`);
|
|
421
|
+
});
|
|
434
422
|
}
|
|
435
423
|
|
|
436
424
|
_setSpeaking(value) {
|