mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-09 16:13:31 +01:00
feat: implement DAVE end-to-end encryption (#10921)
* feat(voice): implement DAVE E2EE encryption * chore(voice): update dependencies * chore(voice): update debug logs and dependency report * feat(voice): emit and propogate DAVESession errors * chore(voice): export dave session things * chore(voice): move expiry numbers to consts * feat(voice): keep track of and pass connected client IDs * fix(voice): dont set initial transitions as pending * feat(voice): dave encryption * chore(voice): directly reference package name in import * feat(voice): dave decryption * chore(deps): update @snazzah/davey * fix(voice): handle decryption failure tolerance * fix(voice): move and update decryption failure logic to DAVESession * feat(voice): propogate voice privacy code * fix(voice): actually send a transition ready when ready * feat(voice): propogate transitions and verification code function * feat(voice): add dave options * chore: resolve format change requests * chore: emit debug messages on bad transitions * chore: downgrade commit/welcome errors as debug messages * chore: resolve formatting change requests * chore: update davey dependency * chore: add types for underlying dave session * fix: fix rebase * chore: change "ID" to "id" in typedocs --------- Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
This commit is contained in:
@@ -344,7 +344,11 @@ describe('VoiceConnection#configureNetworking', () => {
|
|||||||
sessionId: state.session_id,
|
sessionId: state.session_id,
|
||||||
userId: state.user_id,
|
userId: state.user_id,
|
||||||
},
|
},
|
||||||
false,
|
{
|
||||||
|
daveEncryption: true,
|
||||||
|
debug: false,
|
||||||
|
decryptionFailureTolerance: undefined,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(voiceConnection.state).toMatchObject({
|
expect(voiceConnection.state).toMatchObject({
|
||||||
status: VoiceConnectionStatus.Connecting,
|
status: VoiceConnectionStatus.Connecting,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
import { VoiceOpcodes } from 'discord-api-types/voice/v8';
|
||||||
import { describe, test, expect, vitest, beforeEach } from 'vitest';
|
import { describe, test, expect, vitest, beforeEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
RTP_PACKET_DESKTOP,
|
RTP_PACKET_DESKTOP,
|
||||||
@@ -141,36 +141,6 @@ describe('VoiceReceiver', () => {
|
|||||||
userId: '123abc',
|
userId: '123abc',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CLIENT_CONNECT packet', () => {
|
|
||||||
const spy = vitest.spyOn(receiver.ssrcMap, 'update');
|
|
||||||
receiver['onWsPacket']({
|
|
||||||
op: VoiceOpcodes.ClientConnect,
|
|
||||||
d: {
|
|
||||||
audio_ssrc: 123,
|
|
||||||
video_ssrc: 43,
|
|
||||||
user_id: '123abc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(spy).toHaveBeenCalledWith({
|
|
||||||
audioSSRC: 123,
|
|
||||||
videoSSRC: 43,
|
|
||||||
userId: '123abc',
|
|
||||||
});
|
|
||||||
receiver['onWsPacket']({
|
|
||||||
op: VoiceOpcodes.ClientConnect,
|
|
||||||
d: {
|
|
||||||
audio_ssrc: 123,
|
|
||||||
video_ssrc: 0,
|
|
||||||
user_id: '123abc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(spy).toHaveBeenCalledWith({
|
|
||||||
audioSSRC: 123,
|
|
||||||
videoSSRC: undefined,
|
|
||||||
userId: '123abc',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('decrypt', () => {
|
describe('decrypt', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type EventEmitter, once } from 'node:events';
|
import { type EventEmitter, once } from 'node:events';
|
||||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
import { VoiceOpcodes } from 'discord-api-types/voice/v8';
|
||||||
import { describe, test, expect, beforeEach } from 'vitest';
|
import { describe, test, expect, beforeEach } from 'vitest';
|
||||||
import WS from 'vitest-websocket-mock';
|
import WS from 'vitest-websocket-mock';
|
||||||
import { VoiceWebSocket } from '../src/networking/VoiceWebSocket';
|
import { VoiceWebSocket } from '../src/networking/VoiceWebSocket';
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"@discordjs/scripts": "workspace:^",
|
"@discordjs/scripts": "workspace:^",
|
||||||
"@favware/cliff-jumper": "^4.1.0",
|
"@favware/cliff-jumper": "^4.1.0",
|
||||||
"@noble/ciphers": "^1.2.1",
|
"@noble/ciphers": "^1.2.1",
|
||||||
|
"@snazzah/davey": "^0.1.6",
|
||||||
"@types/node": "^22.15.2",
|
"@types/node": "^22.15.2",
|
||||||
"@vitest/coverage-v8": "^3.1.1",
|
"@vitest/coverage-v8": "^3.1.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ export interface VoiceConnection extends EventEmitter {
|
|||||||
* @eventProperty
|
* @eventProperty
|
||||||
*/
|
*/
|
||||||
on(event: 'stateChange', listener: (oldState: VoiceConnectionState, newState: VoiceConnectionState) => void): this;
|
on(event: 'stateChange', listener: (oldState: VoiceConnectionState, newState: VoiceConnectionState) => void): this;
|
||||||
|
/**
|
||||||
|
* Emitted when the end-to-end encrypted session has transitioned
|
||||||
|
*
|
||||||
|
* @eventProperty
|
||||||
|
*/
|
||||||
|
on(event: 'transitioned', listener: (transitionId: number) => void): this;
|
||||||
/**
|
/**
|
||||||
* Emitted when the state of the voice connection changes to a specific status
|
* Emitted when the state of the voice connection changes to a specific status
|
||||||
*
|
*
|
||||||
@@ -235,6 +241,11 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private readonly debug: ((message: string) => void) | null;
|
private readonly debug: ((message: string) => void) | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options used to create this voice connection.
|
||||||
|
*/
|
||||||
|
private readonly options: CreateVoiceConnectionOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new voice connection.
|
* Creates a new voice connection.
|
||||||
*
|
*
|
||||||
@@ -253,6 +264,7 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this);
|
this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this);
|
||||||
this.onNetworkingError = this.onNetworkingError.bind(this);
|
this.onNetworkingError = this.onNetworkingError.bind(this);
|
||||||
this.onNetworkingDebug = this.onNetworkingDebug.bind(this);
|
this.onNetworkingDebug = this.onNetworkingDebug.bind(this);
|
||||||
|
this.onNetworkingTransitioned = this.onNetworkingTransitioned.bind(this);
|
||||||
|
|
||||||
const adapter = options.adapterCreator({
|
const adapter = options.adapterCreator({
|
||||||
onVoiceServerUpdate: (data) => this.addServerPacket(data),
|
onVoiceServerUpdate: (data) => this.addServerPacket(data),
|
||||||
@@ -268,6 +280,7 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.joinConfig = joinConfig;
|
this.joinConfig = joinConfig;
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,6 +308,7 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
oldNetworking.off('error', this.onNetworkingError);
|
oldNetworking.off('error', this.onNetworkingError);
|
||||||
oldNetworking.off('close', this.onNetworkingClose);
|
oldNetworking.off('close', this.onNetworkingClose);
|
||||||
oldNetworking.off('stateChange', this.onNetworkingStateChange);
|
oldNetworking.off('stateChange', this.onNetworkingStateChange);
|
||||||
|
oldNetworking.off('transitioned', this.onNetworkingTransitioned);
|
||||||
oldNetworking.destroy();
|
oldNetworking.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,14 +426,20 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
token: server.token,
|
token: server.token,
|
||||||
sessionId: state.session_id,
|
sessionId: state.session_id,
|
||||||
userId: state.user_id,
|
userId: state.user_id,
|
||||||
|
channelId: state.channel_id!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debug: Boolean(this.debug),
|
||||||
|
daveEncryption: this.options.daveEncryption ?? true,
|
||||||
|
decryptionFailureTolerance: this.options.decryptionFailureTolerance,
|
||||||
},
|
},
|
||||||
Boolean(this.debug),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
networking.once('close', this.onNetworkingClose);
|
networking.once('close', this.onNetworkingClose);
|
||||||
networking.on('stateChange', this.onNetworkingStateChange);
|
networking.on('stateChange', this.onNetworkingStateChange);
|
||||||
networking.on('error', this.onNetworkingError);
|
networking.on('error', this.onNetworkingError);
|
||||||
networking.on('debug', this.onNetworkingDebug);
|
networking.on('debug', this.onNetworkingDebug);
|
||||||
|
networking.on('transitioned', this.onNetworkingTransitioned);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
@@ -509,6 +529,15 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
this.debug?.(`[NW] ${message}`);
|
this.debug?.(`[NW] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagates transitions from the underlying network instance.
|
||||||
|
*
|
||||||
|
* @param transitionId - The transition id
|
||||||
|
*/
|
||||||
|
private onNetworkingTransitioned(transitionId: number) {
|
||||||
|
this.emit('transitioned', transitionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares an audio packet for dispatch.
|
* Prepares an audio packet for dispatch.
|
||||||
*
|
*
|
||||||
@@ -694,6 +723,41 @@ export class VoiceConnection extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current voice privacy code of the encrypted session.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* For this data to be available, the VoiceConnection must be in the Ready state,
|
||||||
|
* and the connection would have to be end-to-end encrypted.
|
||||||
|
*/
|
||||||
|
public get voicePrivacyCode() {
|
||||||
|
if (
|
||||||
|
this.state.status === VoiceConnectionStatus.Ready &&
|
||||||
|
this.state.networking.state.code === NetworkingStatusCode.Ready
|
||||||
|
) {
|
||||||
|
return this.state.networking.state.dave?.voicePrivacyCode ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the verification code for a user in the session.
|
||||||
|
*
|
||||||
|
* @throws Will throw if end-to-end encryption is not on or if the user id provided is not in the session.
|
||||||
|
*/
|
||||||
|
public async getVerificationCode(userId: string): Promise<string> {
|
||||||
|
if (
|
||||||
|
this.state.status === VoiceConnectionStatus.Ready &&
|
||||||
|
this.state.networking.state.code === NetworkingStatusCode.Ready &&
|
||||||
|
this.state.networking.state.dave
|
||||||
|
) {
|
||||||
|
return this.state.networking.state.dave.getVerificationCode(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Session not available');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when a subscription of this voice connection to an audio player is removed.
|
* Called when a subscription of this voice connection to an audio player is removed.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export {
|
|||||||
VoiceUDPSocket,
|
VoiceUDPSocket,
|
||||||
VoiceWebSocket,
|
VoiceWebSocket,
|
||||||
type SocketConfig,
|
type SocketConfig,
|
||||||
|
DAVESession,
|
||||||
} from './networking/index.js';
|
} from './networking/index.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -8,11 +8,22 @@ import type { DiscordGatewayAdapterCreator } from './util/adapter';
|
|||||||
export interface CreateVoiceConnectionOptions {
|
export interface CreateVoiceConnectionOptions {
|
||||||
adapterCreator: DiscordGatewayAdapterCreator;
|
adapterCreator: DiscordGatewayAdapterCreator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to use the DAVE protocol for end-to-end encryption. Defaults to true.
|
||||||
|
*/
|
||||||
|
daveEncryption?: boolean | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If true, debug messages will be enabled for the voice connection and its
|
* If true, debug messages will be enabled for the voice connection and its
|
||||||
* related components. Defaults to false.
|
* related components. Defaults to false.
|
||||||
*/
|
*/
|
||||||
debug?: boolean | undefined;
|
debug?: boolean | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of consecutive decryption failures needed to try to
|
||||||
|
* re-initialize the end-to-end encrypted session to recover. Defaults to 24.
|
||||||
|
*/
|
||||||
|
decryptionFailureTolerance?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,5 +72,7 @@ export function joinVoiceChannel(options: CreateVoiceConnectionOptions & JoinVoi
|
|||||||
return createVoiceConnection(joinConfig, {
|
return createVoiceConnection(joinConfig, {
|
||||||
adapterCreator: options.adapterCreator,
|
adapterCreator: options.adapterCreator,
|
||||||
debug: options.debug,
|
debug: options.debug,
|
||||||
|
daveEncryption: options.daveEncryption,
|
||||||
|
decryptionFailureTolerance: options.decryptionFailureTolerance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
423
packages/voice/src/networking/DAVESession.ts
Normal file
423
packages/voice/src/networking/DAVESession.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { VoiceDavePrepareEpochData, VoiceDavePrepareTransitionData } from 'discord-api-types/voice/v8';
|
||||||
|
import { SILENCE_FRAME } from '../audio/AudioPlayer';
|
||||||
|
|
||||||
|
interface SessionMethods {
|
||||||
|
canPassthrough(userId: string): boolean;
|
||||||
|
decrypt(userId: string, mediaType: 0 | 1, packet: Buffer): Buffer;
|
||||||
|
encryptOpus(packet: Buffer): Buffer;
|
||||||
|
getSerializedKeyPackage(): Buffer;
|
||||||
|
getVerificationCode(userId: string): Promise<string>;
|
||||||
|
processCommit(commit: Buffer): void;
|
||||||
|
processProposals(optype: 0 | 1, proposals: Buffer, recognizedUserIds?: string[]): ProposalsResult;
|
||||||
|
processWelcome(welcome: Buffer): void;
|
||||||
|
ready: boolean;
|
||||||
|
reinit(protocolVersion: number, userId: string, channelId: string): void;
|
||||||
|
reset(): void;
|
||||||
|
setExternalSender(externalSender: Buffer): void;
|
||||||
|
setPassthroughMode(passthrough: boolean, expiry: number): void;
|
||||||
|
voicePrivacyCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProposalsResult {
|
||||||
|
commit?: Buffer;
|
||||||
|
welcome?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Davey: any = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of seconds that a previous transition should be valid for.
|
||||||
|
*/
|
||||||
|
const TRANSITION_EXPIRY = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The arbitrary amount of seconds to allow passthrough for mid-downgrade.
|
||||||
|
* Generally, transitions last about 3 seconds maximum, but this should cover for when connections are delayed.
|
||||||
|
*/
|
||||||
|
const TRANSITION_EXPIRY_PENDING_DOWNGRADE = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of packets to allow decryption failure for until we deem the transition bad and re-initialize.
|
||||||
|
* Usually 4 packets on a good connection may slip past when entering a new session.
|
||||||
|
* After re-initializing, 5-24 packets may fail to decrypt after.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DECRYPTION_FAILURE_TOLERANCE = 36;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
|
export const daveLoadPromise = new Promise<void>(async (resolve) => {
|
||||||
|
try {
|
||||||
|
const lib = await import('@snazzah/davey');
|
||||||
|
Davey = lib;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TransitionResult {
|
||||||
|
success: boolean;
|
||||||
|
transitionId: number;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Options that dictate the session behavior.
|
||||||
|
*/
|
||||||
|
export interface DAVESessionOptions {
|
||||||
|
decryptionFailureTolerance?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum DAVE protocol version supported.
|
||||||
|
*/
|
||||||
|
export function getMaxProtocolVersion(): number | null {
|
||||||
|
return Davey?.DAVE_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DAVESession extends EventEmitter {
|
||||||
|
on(event: 'error', listener: (error: Error) => void): this;
|
||||||
|
on(event: 'debug', listener: (message: string) => void): this;
|
||||||
|
on(event: 'keyPackage', listener: (message: Buffer) => void): this;
|
||||||
|
on(event: 'invalidateTransition', listener: (transitionId: number) => void): this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the DAVE protocol group session.
|
||||||
|
*/
|
||||||
|
export class DAVESession extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* The channel id represented by this session.
|
||||||
|
*/
|
||||||
|
public channelId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user id represented by this session.
|
||||||
|
*/
|
||||||
|
public userId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The protocol version being used.
|
||||||
|
*/
|
||||||
|
public protocolVersion: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last transition id executed.
|
||||||
|
*/
|
||||||
|
public lastTransitionId?: number | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pending transition.
|
||||||
|
*/
|
||||||
|
private pendingTransition?: VoiceDavePrepareTransitionData | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this session was downgraded previously.
|
||||||
|
*/
|
||||||
|
private downgraded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of consecutive failures encountered when decrypting.
|
||||||
|
*/
|
||||||
|
private consecutiveFailures = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of consecutive failures needed to attempt to recover.
|
||||||
|
*/
|
||||||
|
private readonly failureTolerance: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this session is currently re-initializing due to an invalid transition.
|
||||||
|
*/
|
||||||
|
public reinitializing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The underlying DAVE Session of this wrapper.
|
||||||
|
*/
|
||||||
|
public session: SessionMethods | undefined;
|
||||||
|
|
||||||
|
public constructor(protocolVersion: number, userId: string, channelId: string, options: DAVESessionOptions) {
|
||||||
|
if (Davey === null)
|
||||||
|
throw new Error(
|
||||||
|
`Cannot utilize the DAVE protocol as the @snazzah/davey package has not been installed.
|
||||||
|
- Use the generateDependencyReport() function for more information.\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.protocolVersion = protocolVersion;
|
||||||
|
this.userId = userId;
|
||||||
|
this.channelId = channelId;
|
||||||
|
this.failureTolerance = options.decryptionFailureTolerance ?? DEFAULT_DECRYPTION_FAILURE_TOLERANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current voice privacy code of the session. Will be `null` if there is no session.
|
||||||
|
*/
|
||||||
|
public get voicePrivacyCode(): string | null {
|
||||||
|
if (this.protocolVersion === 0 || !this.session?.voicePrivacyCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.session.voicePrivacyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the verification code for a user in the session.
|
||||||
|
*
|
||||||
|
* @throws Will throw if there is not an active session or the user id provided is invalid or not in the session.
|
||||||
|
*/
|
||||||
|
public async getVerificationCode(userId: string): Promise<string> {
|
||||||
|
if (!this.session) throw new Error('Session not available');
|
||||||
|
return this.session.getVerificationCode(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-initializes (or initializes) the underlying session.
|
||||||
|
*/
|
||||||
|
public reinit() {
|
||||||
|
if (this.protocolVersion > 0) {
|
||||||
|
if (this.session) {
|
||||||
|
this.session.reinit(this.protocolVersion, this.userId, this.channelId);
|
||||||
|
this.emit('debug', `Session reinitialized for protocol version ${this.protocolVersion}`);
|
||||||
|
} else {
|
||||||
|
this.session = new Davey.DAVESession(this.protocolVersion, this.userId, this.channelId);
|
||||||
|
this.emit('debug', `Session initialized for protocol version ${this.protocolVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('keyPackage', this.session!.getSerializedKeyPackage());
|
||||||
|
} else if (this.session) {
|
||||||
|
this.session.reset();
|
||||||
|
this.session.setPassthroughMode(true, TRANSITION_EXPIRY);
|
||||||
|
this.emit('debug', 'Session reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the external sender for this session.
|
||||||
|
*
|
||||||
|
* @param externalSender - The external sender
|
||||||
|
*/
|
||||||
|
public setExternalSender(externalSender: Buffer) {
|
||||||
|
if (!this.session) throw new Error('No session available');
|
||||||
|
this.session.setExternalSender(externalSender);
|
||||||
|
this.emit('debug', 'Set MLS external sender');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare for a transition.
|
||||||
|
*
|
||||||
|
* @param data - The transition data
|
||||||
|
* @returns Whether we should signal to the voice server that we are ready
|
||||||
|
*/
|
||||||
|
public prepareTransition(data: VoiceDavePrepareTransitionData) {
|
||||||
|
this.emit('debug', `Preparing for transition (${data.transition_id}, v${data.protocol_version})`);
|
||||||
|
this.pendingTransition = data;
|
||||||
|
|
||||||
|
// When the included transition id is 0, the transition is for (re)initialization and it can be executed immediately.
|
||||||
|
if (data.transition_id === 0) {
|
||||||
|
this.executeTransition(data.transition_id);
|
||||||
|
} else {
|
||||||
|
if (data.protocol_version === 0) this.session?.setPassthroughMode(true, TRANSITION_EXPIRY_PENDING_DOWNGRADE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a transition.
|
||||||
|
*
|
||||||
|
* @param transitionId - The transition id to execute on
|
||||||
|
*/
|
||||||
|
public executeTransition(transitionId: number) {
|
||||||
|
this.emit('debug', `Executing transition (${transitionId})`);
|
||||||
|
if (!this.pendingTransition) {
|
||||||
|
this.emit('debug', `Received execute transition, but we don't have a pending transition for ${transitionId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transitioned = false;
|
||||||
|
if (transitionId === this.pendingTransition.transition_id) {
|
||||||
|
const oldVersion = this.protocolVersion;
|
||||||
|
this.protocolVersion = this.pendingTransition.protocol_version;
|
||||||
|
|
||||||
|
// Handle upgrades & defer downgrades
|
||||||
|
if (oldVersion !== this.protocolVersion && this.protocolVersion === 0) {
|
||||||
|
this.downgraded = true;
|
||||||
|
this.emit('debug', 'Session downgraded');
|
||||||
|
} else if (transitionId > 0 && this.downgraded) {
|
||||||
|
this.downgraded = false;
|
||||||
|
this.session?.setPassthroughMode(true, TRANSITION_EXPIRY);
|
||||||
|
this.emit('debug', 'Session upgraded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the future we'd want to signal to the DAVESession to transition also, but it only supports v1 at this time
|
||||||
|
transitioned = true;
|
||||||
|
this.reinitializing = false;
|
||||||
|
this.lastTransitionId = transitionId;
|
||||||
|
this.emit('debug', `Transition executed (v${oldVersion} -> v${this.protocolVersion}, id: ${transitionId})`);
|
||||||
|
} else {
|
||||||
|
this.emit(
|
||||||
|
'debug',
|
||||||
|
`Received execute transition for an unexpected transition id (expected: ${this.pendingTransition.transition_id}, actual: ${transitionId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingTransition = undefined;
|
||||||
|
return transitioned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare for a new epoch.
|
||||||
|
*
|
||||||
|
* @param data - The epoch data
|
||||||
|
*/
|
||||||
|
public prepareEpoch(data: VoiceDavePrepareEpochData) {
|
||||||
|
this.emit('debug', `Preparing for epoch (${data.epoch})`);
|
||||||
|
if (data.epoch === 1) {
|
||||||
|
this.protocolVersion = data.protocol_version;
|
||||||
|
this.reinit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover from an invalid transition by re-initializing.
|
||||||
|
*
|
||||||
|
* @param transitionId - The transition id to invalidate
|
||||||
|
*/
|
||||||
|
public recoverFromInvalidTransition(transitionId: number) {
|
||||||
|
if (this.reinitializing) return;
|
||||||
|
this.emit('debug', `Invalidating transition ${transitionId}`);
|
||||||
|
this.reinitializing = true;
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.emit('invalidateTransition', transitionId);
|
||||||
|
this.reinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes proposals from the MLS group.
|
||||||
|
*
|
||||||
|
* @param payload - The binary message payload
|
||||||
|
* @param connectedClients - The set of connected client IDs
|
||||||
|
* @returns The payload to send back to the voice server, if there is one
|
||||||
|
*/
|
||||||
|
public processProposals(payload: Buffer, connectedClients: Set<string>): Buffer | undefined {
|
||||||
|
if (!this.session) throw new Error('No session available');
|
||||||
|
const optype = payload.readUInt8(0) as 0 | 1;
|
||||||
|
const { commit, welcome } = this.session.processProposals(
|
||||||
|
optype,
|
||||||
|
payload.subarray(1),
|
||||||
|
Array.from(connectedClients),
|
||||||
|
);
|
||||||
|
this.emit('debug', 'MLS proposals processed');
|
||||||
|
if (!commit) return;
|
||||||
|
return welcome ? Buffer.concat([commit, welcome]) : commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a commit from the MLS group.
|
||||||
|
*
|
||||||
|
* @param payload - The payload
|
||||||
|
* @returns The transaction id and whether it was successful
|
||||||
|
*/
|
||||||
|
public processCommit(payload: Buffer): TransitionResult {
|
||||||
|
if (!this.session) throw new Error('No session available');
|
||||||
|
const transitionId = payload.readUInt16BE(0);
|
||||||
|
try {
|
||||||
|
this.session.processCommit(payload.subarray(2));
|
||||||
|
if (transitionId === 0) {
|
||||||
|
this.reinitializing = false;
|
||||||
|
this.lastTransitionId = transitionId;
|
||||||
|
} else {
|
||||||
|
this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('debug', `MLS commit processed (transition id: ${transitionId})`);
|
||||||
|
return { transitionId, success: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('debug', `MLS commit errored from transition ${transitionId}: ${error}`);
|
||||||
|
this.recoverFromInvalidTransition(transitionId);
|
||||||
|
return { transitionId, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a welcome from the MLS group.
|
||||||
|
*
|
||||||
|
* @param payload - The payload
|
||||||
|
* @returns The transaction id and whether it was successful
|
||||||
|
*/
|
||||||
|
public processWelcome(payload: Buffer): TransitionResult {
|
||||||
|
if (!this.session) throw new Error('No session available');
|
||||||
|
const transitionId = payload.readUInt16BE(0);
|
||||||
|
try {
|
||||||
|
this.session.processWelcome(payload.subarray(2));
|
||||||
|
if (transitionId === 0) {
|
||||||
|
this.reinitializing = false;
|
||||||
|
this.lastTransitionId = transitionId;
|
||||||
|
} else {
|
||||||
|
this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('debug', `MLS welcome processed (transition id: ${transitionId})`);
|
||||||
|
return { transitionId, success: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('debug', `MLS welcome errored from transition ${transitionId}: ${error}`);
|
||||||
|
this.recoverFromInvalidTransition(transitionId);
|
||||||
|
return { transitionId, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a packet using end-to-end encryption.
|
||||||
|
*
|
||||||
|
* @param packet - The packet to encrypt
|
||||||
|
*/
|
||||||
|
public encrypt(packet: Buffer) {
|
||||||
|
if (this.protocolVersion === 0 || !this.session?.ready || packet.equals(SILENCE_FRAME)) return packet;
|
||||||
|
return this.session.encryptOpus(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a packet using end-to-end encryption.
|
||||||
|
*
|
||||||
|
* @param packet - The packet to decrypt
|
||||||
|
* @param userId - The user id that sent the packet
|
||||||
|
* @returns The decrypted packet, or `null` if the decryption failed but should be ignored
|
||||||
|
*/
|
||||||
|
public decrypt(packet: Buffer, userId: string) {
|
||||||
|
const canDecrypt = this.session?.ready && (this.protocolVersion !== 0 || this.session?.canPassthrough(userId));
|
||||||
|
if (packet.equals(SILENCE_FRAME) || !canDecrypt || !this.session) return packet;
|
||||||
|
try {
|
||||||
|
const buffer = this.session.decrypt(userId, Davey.MediaType.AUDIO, packet);
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
return buffer;
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.reinitializing && !this.pendingTransition) {
|
||||||
|
this.consecutiveFailures++;
|
||||||
|
this.emit('debug', `Failed to decrypt a packet (${this.consecutiveFailures} consecutive fails)`);
|
||||||
|
if (this.consecutiveFailures > this.failureTolerance) {
|
||||||
|
if (this.lastTransitionId) this.recoverFromInvalidTransition(this.lastTransitionId);
|
||||||
|
else throw error;
|
||||||
|
}
|
||||||
|
} else if (this.reinitializing) {
|
||||||
|
this.emit('debug', 'Failed to decrypt a packet (reinitializing session)');
|
||||||
|
} else if (this.pendingTransition) {
|
||||||
|
this.emit(
|
||||||
|
'debug',
|
||||||
|
`Failed to decrypt a packet (pending transition ${this.pendingTransition.transition_id} to v${this.pendingTransition.protocol_version})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the session.
|
||||||
|
*/
|
||||||
|
public destroy() {
|
||||||
|
try {
|
||||||
|
this.session?.reset();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
import type { VoiceReceivePayload, VoiceSpeakingFlags } from 'discord-api-types/voice/v8';
|
||||||
|
import { VoiceEncryptionMode, VoiceOpcodes } from 'discord-api-types/voice/v8';
|
||||||
import type { CloseEvent } from 'ws';
|
import type { CloseEvent } from 'ws';
|
||||||
import * as secretbox from '../util/Secretbox';
|
import * as secretbox from '../util/Secretbox';
|
||||||
import { noop } from '../util/util';
|
import { noop } from '../util/util';
|
||||||
|
import { DAVESession, getMaxProtocolVersion } from './DAVESession';
|
||||||
import { VoiceUDPSocket } from './VoiceUDPSocket';
|
import { VoiceUDPSocket } from './VoiceUDPSocket';
|
||||||
|
import type { BinaryWebSocketMessage } from './VoiceWebSocket';
|
||||||
import { VoiceWebSocket } from './VoiceWebSocket';
|
import { VoiceWebSocket } from './VoiceWebSocket';
|
||||||
|
|
||||||
// The number of audio channels required by Discord
|
// The number of audio channels required by Discord
|
||||||
@@ -16,11 +19,11 @@ const CHANNELS = 2;
|
|||||||
const TIMESTAMP_INC = (48_000 / 100) * CHANNELS;
|
const TIMESTAMP_INC = (48_000 / 100) * CHANNELS;
|
||||||
const MAX_NONCE_SIZE = 2 ** 32 - 1;
|
const MAX_NONCE_SIZE = 2 ** 32 - 1;
|
||||||
|
|
||||||
export const SUPPORTED_ENCRYPTION_MODES = ['aead_xchacha20_poly1305_rtpsize'];
|
export const SUPPORTED_ENCRYPTION_MODES: VoiceEncryptionMode[] = [VoiceEncryptionMode.AeadXChaCha20Poly1305RtpSize];
|
||||||
|
|
||||||
// Just in case there's some system that doesn't come with aes-256-gcm, conditionally add it as supported
|
// Just in case there's some system that doesn't come with aes-256-gcm, conditionally add it as supported
|
||||||
if (crypto.getCiphers().includes('aes-256-gcm')) {
|
if (crypto.getCiphers().includes('aes-256-gcm')) {
|
||||||
SUPPORTED_ENCRYPTION_MODES.unshift('aead_aes256_gcm_rtpsize');
|
SUPPORTED_ENCRYPTION_MODES.unshift(VoiceEncryptionMode.AeadAes256GcmRtpSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +66,7 @@ export interface NetworkingIdentifyingState {
|
|||||||
*/
|
*/
|
||||||
export interface NetworkingUdpHandshakingState {
|
export interface NetworkingUdpHandshakingState {
|
||||||
code: NetworkingStatusCode.UdpHandshaking;
|
code: NetworkingStatusCode.UdpHandshaking;
|
||||||
connectionData: Pick<ConnectionData, 'ssrc'>;
|
connectionData: Pick<ConnectionData, 'connectedClients' | 'ssrc'>;
|
||||||
connectionOptions: ConnectionOptions;
|
connectionOptions: ConnectionOptions;
|
||||||
udp: VoiceUDPSocket;
|
udp: VoiceUDPSocket;
|
||||||
ws: VoiceWebSocket;
|
ws: VoiceWebSocket;
|
||||||
@@ -74,7 +77,7 @@ export interface NetworkingUdpHandshakingState {
|
|||||||
*/
|
*/
|
||||||
export interface NetworkingSelectingProtocolState {
|
export interface NetworkingSelectingProtocolState {
|
||||||
code: NetworkingStatusCode.SelectingProtocol;
|
code: NetworkingStatusCode.SelectingProtocol;
|
||||||
connectionData: Pick<ConnectionData, 'ssrc'>;
|
connectionData: Pick<ConnectionData, 'connectedClients' | 'ssrc'>;
|
||||||
connectionOptions: ConnectionOptions;
|
connectionOptions: ConnectionOptions;
|
||||||
udp: VoiceUDPSocket;
|
udp: VoiceUDPSocket;
|
||||||
ws: VoiceWebSocket;
|
ws: VoiceWebSocket;
|
||||||
@@ -88,6 +91,7 @@ export interface NetworkingReadyState {
|
|||||||
code: NetworkingStatusCode.Ready;
|
code: NetworkingStatusCode.Ready;
|
||||||
connectionData: ConnectionData;
|
connectionData: ConnectionData;
|
||||||
connectionOptions: ConnectionOptions;
|
connectionOptions: ConnectionOptions;
|
||||||
|
dave?: DAVESession | undefined;
|
||||||
preparedPacket?: Buffer | undefined;
|
preparedPacket?: Buffer | undefined;
|
||||||
udp: VoiceUDPSocket;
|
udp: VoiceUDPSocket;
|
||||||
ws: VoiceWebSocket;
|
ws: VoiceWebSocket;
|
||||||
@@ -101,6 +105,7 @@ export interface NetworkingResumingState {
|
|||||||
code: NetworkingStatusCode.Resuming;
|
code: NetworkingStatusCode.Resuming;
|
||||||
connectionData: ConnectionData;
|
connectionData: ConnectionData;
|
||||||
connectionOptions: ConnectionOptions;
|
connectionOptions: ConnectionOptions;
|
||||||
|
dave?: DAVESession | undefined;
|
||||||
preparedPacket?: Buffer | undefined;
|
preparedPacket?: Buffer | undefined;
|
||||||
udp: VoiceUDPSocket;
|
udp: VoiceUDPSocket;
|
||||||
ws: VoiceWebSocket;
|
ws: VoiceWebSocket;
|
||||||
@@ -132,6 +137,7 @@ export type NetworkingState =
|
|||||||
* and VOICE_STATE_UPDATE packets.
|
* and VOICE_STATE_UPDATE packets.
|
||||||
*/
|
*/
|
||||||
export interface ConnectionOptions {
|
export interface ConnectionOptions {
|
||||||
|
channelId: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -144,6 +150,7 @@ export interface ConnectionOptions {
|
|||||||
* the connection, timing information for playback of streams.
|
* the connection, timing information for playback of streams.
|
||||||
*/
|
*/
|
||||||
export interface ConnectionData {
|
export interface ConnectionData {
|
||||||
|
connectedClients: Set<string>;
|
||||||
encryptionMode: string;
|
encryptionMode: string;
|
||||||
nonce: number;
|
nonce: number;
|
||||||
nonceBuffer: Buffer;
|
nonceBuffer: Buffer;
|
||||||
@@ -155,6 +162,15 @@ export interface ConnectionData {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for networking that dictate behavior.
|
||||||
|
*/
|
||||||
|
export interface NetworkingOptions {
|
||||||
|
daveEncryption?: boolean | undefined;
|
||||||
|
debug?: boolean | undefined;
|
||||||
|
decryptionFailureTolerance?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An empty buffer that is reused in packet encryption by many different networking instances.
|
* An empty buffer that is reused in packet encryption by many different networking instances.
|
||||||
*/
|
*/
|
||||||
@@ -170,6 +186,7 @@ export interface Networking extends EventEmitter {
|
|||||||
on(event: 'error', listener: (error: Error) => void): this;
|
on(event: 'error', listener: (error: Error) => void): this;
|
||||||
on(event: 'stateChange', listener: (oldState: NetworkingState, newState: NetworkingState) => void): this;
|
on(event: 'stateChange', listener: (oldState: NetworkingState, newState: NetworkingState) => void): this;
|
||||||
on(event: 'close', listener: (code: number) => void): this;
|
on(event: 'close', listener: (code: number) => void): this;
|
||||||
|
on(event: 'transitioned', listener: (transitionId: number) => void): this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,7 +207,7 @@ function stringifyState(state: NetworkingState) {
|
|||||||
*
|
*
|
||||||
* @param options - The available encryption options
|
* @param options - The available encryption options
|
||||||
*/
|
*/
|
||||||
function chooseEncryptionMode(options: string[]): string {
|
function chooseEncryptionMode(options: VoiceEncryptionMode[]): VoiceEncryptionMode {
|
||||||
const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option));
|
const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option));
|
||||||
if (!option) {
|
if (!option) {
|
||||||
// This should only ever happen if the gateway does not give us any encryption modes we support.
|
// This should only ever happen if the gateway does not give us any encryption modes we support.
|
||||||
@@ -220,27 +237,37 @@ export class Networking extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private readonly debug: ((message: string) => void) | null;
|
private readonly debug: ((message: string) => void) | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options used to create this Networking instance.
|
||||||
|
*/
|
||||||
|
private readonly options: NetworkingOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Networking instance.
|
* Creates a new Networking instance.
|
||||||
*/
|
*/
|
||||||
public constructor(options: ConnectionOptions, debug: boolean) {
|
public constructor(connectionOptions: ConnectionOptions, options: NetworkingOptions) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.onWsOpen = this.onWsOpen.bind(this);
|
this.onWsOpen = this.onWsOpen.bind(this);
|
||||||
this.onChildError = this.onChildError.bind(this);
|
this.onChildError = this.onChildError.bind(this);
|
||||||
this.onWsPacket = this.onWsPacket.bind(this);
|
this.onWsPacket = this.onWsPacket.bind(this);
|
||||||
|
this.onWsBinary = this.onWsBinary.bind(this);
|
||||||
this.onWsClose = this.onWsClose.bind(this);
|
this.onWsClose = this.onWsClose.bind(this);
|
||||||
this.onWsDebug = this.onWsDebug.bind(this);
|
this.onWsDebug = this.onWsDebug.bind(this);
|
||||||
this.onUdpDebug = this.onUdpDebug.bind(this);
|
this.onUdpDebug = this.onUdpDebug.bind(this);
|
||||||
this.onUdpClose = this.onUdpClose.bind(this);
|
this.onUdpClose = this.onUdpClose.bind(this);
|
||||||
|
this.onDaveDebug = this.onDaveDebug.bind(this);
|
||||||
|
this.onDaveKeyPackage = this.onDaveKeyPackage.bind(this);
|
||||||
|
this.onDaveInvalidateTransition = this.onDaveInvalidateTransition.bind(this);
|
||||||
|
|
||||||
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
|
this.debug = options?.debug ? (message: string) => this.emit('debug', message) : null;
|
||||||
|
|
||||||
this._state = {
|
this._state = {
|
||||||
code: NetworkingStatusCode.OpeningWs,
|
code: NetworkingStatusCode.OpeningWs,
|
||||||
ws: this.createWebSocket(options.endpoint),
|
ws: this.createWebSocket(connectionOptions.endpoint),
|
||||||
connectionOptions: options,
|
connectionOptions,
|
||||||
};
|
};
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -272,6 +299,7 @@ export class Networking extends EventEmitter {
|
|||||||
oldWs.off('error', this.onChildError);
|
oldWs.off('error', this.onChildError);
|
||||||
oldWs.off('open', this.onWsOpen);
|
oldWs.off('open', this.onWsOpen);
|
||||||
oldWs.off('packet', this.onWsPacket);
|
oldWs.off('packet', this.onWsPacket);
|
||||||
|
oldWs.off('binary', this.onWsBinary);
|
||||||
oldWs.off('close', this.onWsClose);
|
oldWs.off('close', this.onWsClose);
|
||||||
oldWs.destroy();
|
oldWs.destroy();
|
||||||
}
|
}
|
||||||
@@ -287,6 +315,17 @@ export class Networking extends EventEmitter {
|
|||||||
oldUdp.destroy();
|
oldUdp.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldDave = Reflect.get(this._state, 'dave') as DAVESession | undefined;
|
||||||
|
const newDave = Reflect.get(newState, 'dave') as DAVESession | undefined;
|
||||||
|
|
||||||
|
if (oldDave && oldDave !== newDave) {
|
||||||
|
oldDave.off('error', this.onChildError);
|
||||||
|
oldDave.off('debug', this.onDaveDebug);
|
||||||
|
oldDave.off('keyPackage', this.onDaveKeyPackage);
|
||||||
|
oldDave.off('invalidateTransition', this.onDaveInvalidateTransition);
|
||||||
|
oldDave.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
const oldState = this._state;
|
const oldState = this._state;
|
||||||
this._state = newState;
|
this._state = newState;
|
||||||
this.emit('stateChange', oldState, newState);
|
this.emit('stateChange', oldState, newState);
|
||||||
@@ -310,6 +349,7 @@ export class Networking extends EventEmitter {
|
|||||||
ws.on('error', this.onChildError);
|
ws.on('error', this.onChildError);
|
||||||
ws.once('open', this.onWsOpen);
|
ws.once('open', this.onWsOpen);
|
||||||
ws.on('packet', this.onWsPacket);
|
ws.on('packet', this.onWsPacket);
|
||||||
|
ws.on('binary', this.onWsBinary);
|
||||||
ws.once('close', this.onWsClose);
|
ws.once('close', this.onWsClose);
|
||||||
ws.on('debug', this.onWsDebug);
|
ws.on('debug', this.onWsDebug);
|
||||||
|
|
||||||
@@ -317,7 +357,41 @@ export class Networking extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Propagates errors from the children VoiceWebSocket and VoiceUDPSocket.
|
* Creates a new DAVE session for this voice connection if we can create one.
|
||||||
|
*
|
||||||
|
* @param protocolVersion - The protocol version to use
|
||||||
|
*/
|
||||||
|
private createDaveSession(protocolVersion: number) {
|
||||||
|
if (
|
||||||
|
getMaxProtocolVersion() === null ||
|
||||||
|
this.options.daveEncryption === false ||
|
||||||
|
(this.state.code !== NetworkingStatusCode.SelectingProtocol &&
|
||||||
|
this.state.code !== NetworkingStatusCode.Ready &&
|
||||||
|
this.state.code !== NetworkingStatusCode.Resuming)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new DAVESession(
|
||||||
|
protocolVersion,
|
||||||
|
this.state.connectionOptions.userId,
|
||||||
|
this.state.connectionOptions.channelId,
|
||||||
|
{
|
||||||
|
decryptionFailureTolerance: this.options.decryptionFailureTolerance,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
session.on('error', this.onChildError);
|
||||||
|
session.on('debug', this.onDaveDebug);
|
||||||
|
session.on('keyPackage', this.onDaveKeyPackage);
|
||||||
|
session.on('invalidateTransition', this.onDaveInvalidateTransition);
|
||||||
|
session.reinit();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagates errors from the children VoiceWebSocket, VoiceUDPSocket and DAVESession.
|
||||||
*
|
*
|
||||||
* @param error - The error that was emitted by a child
|
* @param error - The error that was emitted by a child
|
||||||
*/
|
*/
|
||||||
@@ -331,22 +405,22 @@ export class Networking extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private onWsOpen() {
|
private onWsOpen() {
|
||||||
if (this.state.code === NetworkingStatusCode.OpeningWs) {
|
if (this.state.code === NetworkingStatusCode.OpeningWs) {
|
||||||
const packet = {
|
this.state.ws.sendPacket({
|
||||||
op: VoiceOpcodes.Identify,
|
op: VoiceOpcodes.Identify,
|
||||||
d: {
|
d: {
|
||||||
server_id: this.state.connectionOptions.serverId,
|
server_id: this.state.connectionOptions.serverId,
|
||||||
user_id: this.state.connectionOptions.userId,
|
user_id: this.state.connectionOptions.userId,
|
||||||
session_id: this.state.connectionOptions.sessionId,
|
session_id: this.state.connectionOptions.sessionId,
|
||||||
token: this.state.connectionOptions.token,
|
token: this.state.connectionOptions.token,
|
||||||
|
max_dave_protocol_version: this.options.daveEncryption === false ? 0 : (getMaxProtocolVersion() ?? 0),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
this.state.ws.sendPacket(packet);
|
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
code: NetworkingStatusCode.Identifying,
|
code: NetworkingStatusCode.Identifying,
|
||||||
};
|
};
|
||||||
} else if (this.state.code === NetworkingStatusCode.Resuming) {
|
} else if (this.state.code === NetworkingStatusCode.Resuming) {
|
||||||
const packet = {
|
this.state.ws.sendPacket({
|
||||||
op: VoiceOpcodes.Resume,
|
op: VoiceOpcodes.Resume,
|
||||||
d: {
|
d: {
|
||||||
server_id: this.state.connectionOptions.serverId,
|
server_id: this.state.connectionOptions.serverId,
|
||||||
@@ -354,8 +428,7 @@ export class Networking extends EventEmitter {
|
|||||||
token: this.state.connectionOptions.token,
|
token: this.state.connectionOptions.token,
|
||||||
seq_ack: this.state.ws.sequence,
|
seq_ack: this.state.ws.sequence,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
this.state.ws.sendPacket(packet);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +473,7 @@ export class Networking extends EventEmitter {
|
|||||||
*
|
*
|
||||||
* @param packet - The received packet
|
* @param packet - The received packet
|
||||||
*/
|
*/
|
||||||
private onWsPacket(packet: any) {
|
private onWsPacket(packet: VoiceReceivePayload) {
|
||||||
if (packet.op === VoiceOpcodes.Hello && this.state.code !== NetworkingStatusCode.Closed) {
|
if (packet.op === VoiceOpcodes.Hello && this.state.code !== NetworkingStatusCode.Closed) {
|
||||||
this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval);
|
this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval);
|
||||||
} else if (packet.op === VoiceOpcodes.Ready && this.state.code === NetworkingStatusCode.Identifying) {
|
} else if (packet.op === VoiceOpcodes.Ready && this.state.code === NetworkingStatusCode.Identifying) {
|
||||||
@@ -440,16 +513,18 @@ export class Networking extends EventEmitter {
|
|||||||
udp,
|
udp,
|
||||||
connectionData: {
|
connectionData: {
|
||||||
ssrc,
|
ssrc,
|
||||||
|
connectedClients: new Set(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (
|
} else if (
|
||||||
packet.op === VoiceOpcodes.SessionDescription &&
|
packet.op === VoiceOpcodes.SessionDescription &&
|
||||||
this.state.code === NetworkingStatusCode.SelectingProtocol
|
this.state.code === NetworkingStatusCode.SelectingProtocol
|
||||||
) {
|
) {
|
||||||
const { mode: encryptionMode, secret_key: secretKey } = packet.d;
|
const { mode: encryptionMode, secret_key: secretKey, dave_protocol_version: daveProtocolVersion } = packet.d;
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
code: NetworkingStatusCode.Ready,
|
code: NetworkingStatusCode.Ready,
|
||||||
|
dave: this.createDaveSession(daveProtocolVersion),
|
||||||
connectionData: {
|
connectionData: {
|
||||||
...this.state.connectionData,
|
...this.state.connectionData,
|
||||||
encryptionMode,
|
encryptionMode,
|
||||||
@@ -468,9 +543,99 @@ export class Networking extends EventEmitter {
|
|||||||
code: NetworkingStatusCode.Ready,
|
code: NetworkingStatusCode.Ready,
|
||||||
};
|
};
|
||||||
this.state.connectionData.speaking = false;
|
this.state.connectionData.speaking = false;
|
||||||
|
} else if (
|
||||||
|
(packet.op === VoiceOpcodes.ClientsConnect || packet.op === VoiceOpcodes.ClientDisconnect) &&
|
||||||
|
(this.state.code === NetworkingStatusCode.Ready ||
|
||||||
|
this.state.code === NetworkingStatusCode.UdpHandshaking ||
|
||||||
|
this.state.code === NetworkingStatusCode.SelectingProtocol ||
|
||||||
|
this.state.code === NetworkingStatusCode.Resuming)
|
||||||
|
) {
|
||||||
|
const { connectionData } = this.state;
|
||||||
|
if (packet.op === VoiceOpcodes.ClientsConnect)
|
||||||
|
for (const id of packet.d.user_ids) connectionData.connectedClients.add(id);
|
||||||
|
else {
|
||||||
|
connectionData.connectedClients.delete(packet.d.user_id);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
(this.state.code === NetworkingStatusCode.Ready || this.state.code === NetworkingStatusCode.Resuming) &&
|
||||||
|
this.state.dave
|
||||||
|
) {
|
||||||
|
if (packet.op === VoiceOpcodes.DavePrepareTransition) {
|
||||||
|
const sendReady = this.state.dave.prepareTransition(packet.d);
|
||||||
|
if (sendReady)
|
||||||
|
this.state.ws.sendPacket({
|
||||||
|
op: VoiceOpcodes.DaveTransitionReady,
|
||||||
|
d: { transition_id: packet.d.transition_id },
|
||||||
|
});
|
||||||
|
if (packet.d.transition_id === 0) {
|
||||||
|
this.emit('transitioned', 0);
|
||||||
|
}
|
||||||
|
} else if (packet.op === VoiceOpcodes.DaveExecuteTransition) {
|
||||||
|
const transitioned = this.state.dave.executeTransition(packet.d.transition_id);
|
||||||
|
if (transitioned) this.emit('transitioned', packet.d.transition_id);
|
||||||
|
} else if (packet.op === VoiceOpcodes.DavePrepareEpoch) this.state.dave.prepareEpoch(packet.d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a binary message is received on the connection's WebSocket.
|
||||||
|
*
|
||||||
|
* @param message - The received message
|
||||||
|
*/
|
||||||
|
private onWsBinary(message: BinaryWebSocketMessage) {
|
||||||
|
if (this.state.code === NetworkingStatusCode.Ready && this.state.dave) {
|
||||||
|
if (message.op === VoiceOpcodes.DaveMlsExternalSender) {
|
||||||
|
this.state.dave.setExternalSender(message.payload);
|
||||||
|
} else if (message.op === VoiceOpcodes.DaveMlsProposals) {
|
||||||
|
const payload = this.state.dave.processProposals(message.payload, this.state.connectionData.connectedClients);
|
||||||
|
if (payload) this.state.ws.sendBinaryMessage(VoiceOpcodes.DaveMlsCommitWelcome, payload);
|
||||||
|
} else if (message.op === VoiceOpcodes.DaveMlsAnnounceCommitTransition) {
|
||||||
|
const { transitionId, success } = this.state.dave.processCommit(message.payload);
|
||||||
|
if (success) {
|
||||||
|
if (transitionId === 0) this.emit('transitioned', transitionId);
|
||||||
|
else
|
||||||
|
this.state.ws.sendPacket({
|
||||||
|
op: VoiceOpcodes.DaveTransitionReady,
|
||||||
|
d: { transition_id: transitionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (message.op === VoiceOpcodes.DaveMlsWelcome) {
|
||||||
|
const { transitionId, success } = this.state.dave.processWelcome(message.payload);
|
||||||
|
if (success) {
|
||||||
|
if (transitionId === 0) this.emit('transitioned', transitionId);
|
||||||
|
else
|
||||||
|
this.state.ws.sendPacket({
|
||||||
|
op: VoiceOpcodes.DaveTransitionReady,
|
||||||
|
d: { transition_id: transitionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a new key package is ready to be sent to the voice server.
|
||||||
|
*
|
||||||
|
* @param keyPackage - The new key package
|
||||||
|
*/
|
||||||
|
private onDaveKeyPackage(keyPackage: Buffer) {
|
||||||
|
if (this.state.code === NetworkingStatusCode.SelectingProtocol || this.state.code === NetworkingStatusCode.Ready)
|
||||||
|
this.state.ws.sendBinaryMessage(VoiceOpcodes.DaveMlsKeyPackage, keyPackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the DAVE session wants to invalidate their transition and re-initialize.
|
||||||
|
*
|
||||||
|
* @param transitionId - The transition to invalidate
|
||||||
|
*/
|
||||||
|
private onDaveInvalidateTransition(transitionId: number) {
|
||||||
|
if (this.state.code === NetworkingStatusCode.SelectingProtocol || this.state.code === NetworkingStatusCode.Ready)
|
||||||
|
this.state.ws.sendPacket({
|
||||||
|
op: VoiceOpcodes.DaveMlsInvalidCommitWelcome,
|
||||||
|
d: { transition_id: transitionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Propagates debug messages from the child WebSocket.
|
* Propagates debug messages from the child WebSocket.
|
||||||
*
|
*
|
||||||
@@ -489,6 +654,15 @@ export class Networking extends EventEmitter {
|
|||||||
this.debug?.(`[UDP] ${message}`);
|
this.debug?.(`[UDP] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagates debug messages from the child DAVESession.
|
||||||
|
*
|
||||||
|
* @param message - The emitted debug message
|
||||||
|
*/
|
||||||
|
private onDaveDebug(message: string) {
|
||||||
|
this.debug?.(`[DAVE] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it.
|
* Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it.
|
||||||
* It will be stored within the instance, and can be played by dispatchAudio()
|
* It will be stored within the instance, and can be played by dispatchAudio()
|
||||||
@@ -502,7 +676,7 @@ export class Networking extends EventEmitter {
|
|||||||
public prepareAudioPacket(opusPacket: Buffer) {
|
public prepareAudioPacket(opusPacket: Buffer) {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
if (state.code !== NetworkingStatusCode.Ready) return;
|
if (state.code !== NetworkingStatusCode.Ready) return;
|
||||||
state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData);
|
state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData, state.dave);
|
||||||
return state.preparedPacket;
|
return state.preparedPacket;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +728,7 @@ export class Networking extends EventEmitter {
|
|||||||
state.ws.sendPacket({
|
state.ws.sendPacket({
|
||||||
op: VoiceOpcodes.Speaking,
|
op: VoiceOpcodes.Speaking,
|
||||||
d: {
|
d: {
|
||||||
speaking: speaking ? 1 : 0,
|
speaking: (speaking ? 1 : 0) as VoiceSpeakingFlags,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
ssrc: state.connectionData.ssrc,
|
ssrc: state.connectionData.ssrc,
|
||||||
},
|
},
|
||||||
@@ -567,8 +741,9 @@ export class Networking extends EventEmitter {
|
|||||||
*
|
*
|
||||||
* @param opusPacket - The Opus packet to prepare
|
* @param opusPacket - The Opus packet to prepare
|
||||||
* @param connectionData - The current connection data of the instance
|
* @param connectionData - The current connection data of the instance
|
||||||
|
* @param daveSession - The DAVE session to use for encryption
|
||||||
*/
|
*/
|
||||||
private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) {
|
private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData, daveSession?: DAVESession) {
|
||||||
const rtpHeader = Buffer.alloc(12);
|
const rtpHeader = Buffer.alloc(12);
|
||||||
rtpHeader[0] = 0x80;
|
rtpHeader[0] = 0x80;
|
||||||
rtpHeader[1] = 0x78;
|
rtpHeader[1] = 0x78;
|
||||||
@@ -580,7 +755,7 @@ export class Networking extends EventEmitter {
|
|||||||
rtpHeader.writeUIntBE(ssrc, 8, 4);
|
rtpHeader.writeUIntBE(ssrc, 8, 4);
|
||||||
|
|
||||||
rtpHeader.copy(nonce, 0, 0, 12);
|
rtpHeader.copy(nonce, 0, 0, 12);
|
||||||
return Buffer.concat([rtpHeader, ...this.encryptOpusPacket(opusPacket, connectionData, rtpHeader)]);
|
return Buffer.concat([rtpHeader, ...this.encryptOpusPacket(opusPacket, connectionData, rtpHeader, daveSession)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -588,10 +763,18 @@ export class Networking extends EventEmitter {
|
|||||||
*
|
*
|
||||||
* @param opusPacket - The Opus packet to encrypt
|
* @param opusPacket - The Opus packet to encrypt
|
||||||
* @param connectionData - The current connection data of the instance
|
* @param connectionData - The current connection data of the instance
|
||||||
|
* @param daveSession - The DAVE session to use for encryption
|
||||||
*/
|
*/
|
||||||
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData, additionalData: Buffer) {
|
private encryptOpusPacket(
|
||||||
|
opusPacket: Buffer,
|
||||||
|
connectionData: ConnectionData,
|
||||||
|
additionalData: Buffer,
|
||||||
|
daveSession?: DAVESession,
|
||||||
|
) {
|
||||||
const { secretKey, encryptionMode } = connectionData;
|
const { secretKey, encryptionMode } = connectionData;
|
||||||
|
|
||||||
|
const packet = daveSession?.encrypt(opusPacket) ?? opusPacket;
|
||||||
|
|
||||||
// Both supported encryption methods want the nonce to be an incremental integer
|
// Both supported encryption methods want the nonce to be an incremental integer
|
||||||
connectionData.nonce++;
|
connectionData.nonce++;
|
||||||
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
|
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
|
||||||
@@ -606,14 +789,14 @@ export class Networking extends EventEmitter {
|
|||||||
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, connectionData.nonceBuffer);
|
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, connectionData.nonceBuffer);
|
||||||
cipher.setAAD(additionalData);
|
cipher.setAAD(additionalData);
|
||||||
|
|
||||||
encrypted = Buffer.concat([cipher.update(opusPacket), cipher.final(), cipher.getAuthTag()]);
|
encrypted = Buffer.concat([cipher.update(packet), cipher.final(), cipher.getAuthTag()]);
|
||||||
|
|
||||||
return [encrypted, noncePadding];
|
return [encrypted, noncePadding];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'aead_xchacha20_poly1305_rtpsize': {
|
case 'aead_xchacha20_poly1305_rtpsize': {
|
||||||
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||||
opusPacket,
|
packet,
|
||||||
additionalData,
|
additionalData,
|
||||||
connectionData.nonceBuffer,
|
connectionData.nonceBuffer,
|
||||||
secretKey,
|
secretKey,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
import type { VoiceSendPayload } from 'discord-api-types/voice/v8';
|
||||||
|
import { VoiceOpcodes } from 'discord-api-types/voice/v8';
|
||||||
import WebSocket, { type MessageEvent } from 'ws';
|
import WebSocket, { type MessageEvent } from 'ws';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A binary WebSocket message.
|
||||||
|
*/
|
||||||
|
export interface BinaryWebSocketMessage {
|
||||||
|
op: VoiceOpcodes;
|
||||||
|
payload: Buffer;
|
||||||
|
seq: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VoiceWebSocket extends EventEmitter {
|
export interface VoiceWebSocket extends EventEmitter {
|
||||||
on(event: 'error', listener: (error: Error) => void): this;
|
on(event: 'error', listener: (error: Error) => void): this;
|
||||||
on(event: 'open', listener: (event: WebSocket.Event) => void): this;
|
on(event: 'open', listener: (event: WebSocket.Event) => void): this;
|
||||||
@@ -18,6 +29,12 @@ export interface VoiceWebSocket extends EventEmitter {
|
|||||||
* @eventProperty
|
* @eventProperty
|
||||||
*/
|
*/
|
||||||
on(event: 'packet', listener: (packet: any) => void): this;
|
on(event: 'packet', listener: (packet: any) => void): this;
|
||||||
|
/**
|
||||||
|
* Binary message event.
|
||||||
|
*
|
||||||
|
* @eventProperty
|
||||||
|
*/
|
||||||
|
on(event: 'binary', listener: (message: BinaryWebSocketMessage) => void): this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,12 +119,25 @@ export class VoiceWebSocket extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them
|
* Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them
|
||||||
* as packets.
|
* as packets. Binary messages will be parsed and emitted.
|
||||||
*
|
*
|
||||||
* @param event - The message event
|
* @param event - The message event
|
||||||
*/
|
*/
|
||||||
public onMessage(event: MessageEvent) {
|
public onMessage(event: MessageEvent) {
|
||||||
if (typeof event.data !== 'string') return;
|
if (event.data instanceof Buffer || event.data instanceof ArrayBuffer) {
|
||||||
|
const buffer = event.data instanceof ArrayBuffer ? Buffer.from(event.data) : event.data;
|
||||||
|
const seq = buffer.readUInt16BE(0);
|
||||||
|
const op = buffer.readUInt8(2);
|
||||||
|
const payload = buffer.subarray(3);
|
||||||
|
|
||||||
|
this.sequence = seq;
|
||||||
|
this.debug?.(`<< [bin] opcode ${op}, seq ${seq}, ${payload.byteLength} bytes`);
|
||||||
|
|
||||||
|
this.emit('binary', { op, seq, payload });
|
||||||
|
return;
|
||||||
|
} else if (typeof event.data !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.debug?.(`<< ${event.data}`);
|
this.debug?.(`<< ${event.data}`);
|
||||||
|
|
||||||
@@ -138,7 +168,7 @@ export class VoiceWebSocket extends EventEmitter {
|
|||||||
*
|
*
|
||||||
* @param packet - The packet to send
|
* @param packet - The packet to send
|
||||||
*/
|
*/
|
||||||
public sendPacket(packet: any) {
|
public sendPacket(packet: VoiceSendPayload) {
|
||||||
try {
|
try {
|
||||||
const stringified = JSON.stringify(packet);
|
const stringified = JSON.stringify(packet);
|
||||||
this.debug?.(`>> ${stringified}`);
|
this.debug?.(`>> ${stringified}`);
|
||||||
@@ -149,6 +179,23 @@ export class VoiceWebSocket extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a binary mesasge over the WebSocket.
|
||||||
|
*
|
||||||
|
* @param opcode - The opcode to use
|
||||||
|
* @param payload - The payload to send
|
||||||
|
*/
|
||||||
|
public sendBinaryMessage(opcode: VoiceOpcodes, payload: Buffer) {
|
||||||
|
try {
|
||||||
|
const message = Buffer.concat([new Uint8Array([opcode]), payload]);
|
||||||
|
this.debug?.(`>> [bin] opcode ${opcode}, ${payload.byteLength} bytes`);
|
||||||
|
this.ws.send(message);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.emit('error', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a heartbeat over the WebSocket.
|
* Sends a heartbeat over the WebSocket.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './Networking';
|
export * from './Networking';
|
||||||
export * from './VoiceUDPSocket';
|
export * from './VoiceUDPSocket';
|
||||||
export * from './VoiceWebSocket';
|
export * from './VoiceWebSocket';
|
||||||
|
export * from './DAVESession';
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
import type { VoiceReceivePayload } from 'discord-api-types/voice/v8';
|
||||||
import type { VoiceConnection } from '../VoiceConnection';
|
import { VoiceOpcodes } from 'discord-api-types/voice/v8';
|
||||||
import type { ConnectionData } from '../networking/Networking';
|
import { VoiceConnectionStatus, type VoiceConnection } from '../VoiceConnection';
|
||||||
|
import { NetworkingStatusCode, type ConnectionData } from '../networking/Networking';
|
||||||
import { methods } from '../util/Secretbox';
|
import { methods } from '../util/Secretbox';
|
||||||
import {
|
import {
|
||||||
AudioReceiveStream,
|
AudioReceiveStream,
|
||||||
@@ -69,25 +70,11 @@ export class VoiceReceiver {
|
|||||||
* @param packet - The received packet
|
* @param packet - The received packet
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
public onWsPacket(packet: any) {
|
public onWsPacket(packet: VoiceReceivePayload) {
|
||||||
if (packet.op === VoiceOpcodes.ClientDisconnect && typeof packet.d?.user_id === 'string') {
|
if (packet.op === VoiceOpcodes.ClientDisconnect) {
|
||||||
this.ssrcMap.delete(packet.d.user_id);
|
this.ssrcMap.delete(packet.d.user_id);
|
||||||
} else if (
|
} else if (packet.op === VoiceOpcodes.Speaking) {
|
||||||
packet.op === VoiceOpcodes.Speaking &&
|
|
||||||
typeof packet.d?.user_id === 'string' &&
|
|
||||||
typeof packet.d?.ssrc === 'number'
|
|
||||||
) {
|
|
||||||
this.ssrcMap.update({ userId: packet.d.user_id, audioSSRC: packet.d.ssrc });
|
this.ssrcMap.update({ userId: packet.d.user_id, audioSSRC: packet.d.ssrc });
|
||||||
} else if (
|
|
||||||
packet.op === VoiceOpcodes.ClientConnect &&
|
|
||||||
typeof packet.d?.user_id === 'string' &&
|
|
||||||
typeof packet.d?.audio_ssrc === 'number'
|
|
||||||
) {
|
|
||||||
this.ssrcMap.update({
|
|
||||||
userId: packet.d.user_id,
|
|
||||||
audioSSRC: packet.d.audio_ssrc,
|
|
||||||
videoSSRC: packet.d.video_ssrc === 0 ? undefined : packet.d.video_ssrc,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,11 +130,12 @@ export class VoiceReceiver {
|
|||||||
* @param mode - The encryption mode
|
* @param mode - The encryption mode
|
||||||
* @param nonce - The nonce buffer used by the connection for encryption
|
* @param nonce - The nonce buffer used by the connection for encryption
|
||||||
* @param secretKey - The secret key used by the connection for encryption
|
* @param secretKey - The secret key used by the connection for encryption
|
||||||
|
* @param userId - The user id that sent the packet
|
||||||
* @returns The parsed Opus packet
|
* @returns The parsed Opus packet
|
||||||
*/
|
*/
|
||||||
private parsePacket(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array) {
|
private parsePacket(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array, userId: string) {
|
||||||
let packet = this.decrypt(buffer, mode, nonce, secretKey);
|
let packet = this.decrypt(buffer, mode, nonce, secretKey);
|
||||||
if (!packet) return;
|
if (!packet) throw new Error('Failed to parse packet');
|
||||||
|
|
||||||
// Strip decrypted RTP Header Extension if present
|
// Strip decrypted RTP Header Extension if present
|
||||||
// The header is only indicated in the original data, so compare with buffer first
|
// The header is only indicated in the original data, so compare with buffer first
|
||||||
@@ -156,6 +144,16 @@ export class VoiceReceiver {
|
|||||||
packet = packet.subarray(4 * headerExtensionLength);
|
packet = packet.subarray(4 * headerExtensionLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt packet if in a DAVE session.
|
||||||
|
if (
|
||||||
|
this.voiceConnection.state.status === VoiceConnectionStatus.Ready &&
|
||||||
|
(this.voiceConnection.state.networking.state.code === NetworkingStatusCode.Ready ||
|
||||||
|
this.voiceConnection.state.networking.state.code === NetworkingStatusCode.Resuming)
|
||||||
|
) {
|
||||||
|
const daveSession = this.voiceConnection.state.networking.state.dave;
|
||||||
|
if (daveSession) packet = daveSession.decrypt(packet, userId)!;
|
||||||
|
}
|
||||||
|
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,16 +176,17 @@ export class VoiceReceiver {
|
|||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
|
|
||||||
if (this.connectionData.encryptionMode && this.connectionData.nonceBuffer && this.connectionData.secretKey) {
|
if (this.connectionData.encryptionMode && this.connectionData.nonceBuffer && this.connectionData.secretKey) {
|
||||||
const packet = this.parsePacket(
|
try {
|
||||||
msg,
|
const packet = this.parsePacket(
|
||||||
this.connectionData.encryptionMode,
|
msg,
|
||||||
this.connectionData.nonceBuffer,
|
this.connectionData.encryptionMode,
|
||||||
this.connectionData.secretKey,
|
this.connectionData.nonceBuffer,
|
||||||
);
|
this.connectionData.secretKey,
|
||||||
if (packet) {
|
userData.userId,
|
||||||
stream.push(packet);
|
);
|
||||||
} else {
|
if (packet) stream.push(packet);
|
||||||
stream.destroy(new Error('Failed to parse packet'));
|
} catch (error) {
|
||||||
|
stream.destroy(error as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export function generateDependencyReport() {
|
|||||||
addVersion('@noble/ciphers');
|
addVersion('@noble/ciphers');
|
||||||
report.push('');
|
report.push('');
|
||||||
|
|
||||||
|
// dave
|
||||||
|
report.push('DAVE Libraries');
|
||||||
|
addVersion('@snazzah/davey');
|
||||||
|
report.push('');
|
||||||
|
|
||||||
// ffmpeg
|
// ffmpeg
|
||||||
report.push('FFmpeg');
|
report.push('FFmpeg');
|
||||||
try {
|
try {
|
||||||
|
|||||||
216
pnpm-lock.yaml
generated
216
pnpm-lock.yaml
generated
@@ -1502,7 +1502,7 @@ importers:
|
|||||||
version: 7.8.0
|
version: 7.8.0
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.7.1
|
specifier: ^2.7.1
|
||||||
version: 2.7.1
|
version: 2.8.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@turbo/gen':
|
'@turbo/gen':
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
@@ -1536,7 +1536,7 @@ importers:
|
|||||||
version: 5.39.0
|
version: 5.39.0
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.4.0
|
specifier: ^8.4.0
|
||||||
version: 8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.7.1)
|
version: 8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0)
|
||||||
turbo:
|
turbo:
|
||||||
specifier: ^2.5.2
|
specifier: ^2.5.2
|
||||||
version: 2.5.3
|
version: 2.5.3
|
||||||
@@ -1798,6 +1798,9 @@ importers:
|
|||||||
'@noble/ciphers':
|
'@noble/ciphers':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1
|
version: 1.2.1
|
||||||
|
'@snazzah/davey':
|
||||||
|
specifier: ^0.1.6
|
||||||
|
version: 0.1.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.15.2
|
specifier: ^22.15.2
|
||||||
version: 22.15.26
|
version: 22.15.26
|
||||||
@@ -3831,6 +3834,9 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@0.2.10':
|
'@napi-rs/wasm-runtime@0.2.10':
|
||||||
resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==}
|
resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==}
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@0.2.11':
|
||||||
|
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
|
||||||
|
|
||||||
'@neondatabase/serverless@0.9.5':
|
'@neondatabase/serverless@0.9.5':
|
||||||
resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==}
|
resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==}
|
||||||
|
|
||||||
@@ -5928,6 +5934,93 @@ packages:
|
|||||||
resolution: {integrity: sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA==}
|
resolution: {integrity: sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@snazzah/davey-android-arm-eabi@0.1.6':
|
||||||
|
resolution: {integrity: sha512-6Fso+kxvvIcmUdTgU4etHjvEZUwGwvIk+SUYxKTRZKz/S62pZvcFeZfbofpQC5ZIlt/rdp7l+4IM62J7PUduxQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@snazzah/davey-android-arm64@0.1.6':
|
||||||
|
resolution: {integrity: sha512-5ZGLumjewJAmGAcHqSHb2+KZSSufdNY++/GouzqdQXfhs2bSNBPuHpNn94u6//5UK0o73udJ6B1H/uLOLfEBLQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@snazzah/davey-darwin-arm64@0.1.6':
|
||||||
|
resolution: {integrity: sha512-0k6gOm29bcznz4ND1gfJVKeCxfyFw/EtfhPQvQ2PPJToSIaSvVqfYIlj/v9ogWW/lzuPI4EbLP0b6hnZkKidbQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@snazzah/davey-darwin-x64@0.1.6':
|
||||||
|
resolution: {integrity: sha512-y9UuymB5JTi9LSwjsCZDf/mjI6nAum1+uYX2h4xdO+VUxXQSAR4B2mr3lCI7l9KwYqW7JVDN5wETithAkXcTYA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@snazzah/davey-freebsd-x64@0.1.6':
|
||||||
|
resolution: {integrity: sha512-G0XzHi+pZqTZ5Zr7Z66J6oGOG07+Obw7f0CwD9nAJcSFlKnd8wYzTjL+krHfQxmLHnuA5w/9df+M9oDJDcGcJw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-arm-gnueabihf@0.1.6':
|
||||||
|
resolution: {integrity: sha512-RaxTzO8iJfDvj4a8OcXRwcP+2WfaCcno28ZWFMTI0pHEviG3MfLH5COAIvtMQvg0XfC+HgFC4YA1d29S8Dhvbg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-arm64-gnu@0.1.6':
|
||||||
|
resolution: {integrity: sha512-2BIJSWs4rHq4U9A7B6WtF1LzwYJrbFUz5SQVmwqwQXKJ8cm81iizqclDGWr3zFGiVPTXLZ/+G3wnQNDB54oABQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-arm64-musl@0.1.6':
|
||||||
|
resolution: {integrity: sha512-N1egO+HT8cvSdIGCzJNRVH3ZhxCIYKVYxEkfzVZaBx26snN8NF737YTVRldl84w//3tdgohyl27yrn+dMkWS2Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-x64-gnu@0.1.6':
|
||||||
|
resolution: {integrity: sha512-RAC96Y//HHoMP+1MUf4rOkBq5Nx6GCiOGeGsNXt7r02lbIthoFEPYFQdbfc9jYA79k67gpzmCa0N5ws7ZLVU5A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-x64-musl@0.1.6':
|
||||||
|
resolution: {integrity: sha512-nBvxJTKlQFP9UsQ7ah78L+rGdcwLWKDR8z/knut/M+UZLe37vaponJAbY3F5ZqGAcfqJbwUi/CXR77t9E+TDmw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@snazzah/davey-wasm32-wasi@0.1.6':
|
||||||
|
resolution: {integrity: sha512-hvpZH6a4mYZiXv6vdZaFwjPProgFtb3k4BoMvEEJZDXsEPuIDgp+d2BX5Q9nVazdnJa/6JR/XCuObzugPWp0Ew==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@snazzah/davey-win32-arm64-msvc@0.1.6':
|
||||||
|
resolution: {integrity: sha512-iuxYXXa0Z8eAEZotAlMYUc5DCy3VonRXQMm8/w2EvM/ZzGBI7SMap0GhPf6HjArEW32ETarTLh1s/Yi/jhFPDQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@snazzah/davey-win32-ia32-msvc@0.1.6':
|
||||||
|
resolution: {integrity: sha512-QEubcCIBR+ZZoQzRzJuOuKcH2IaF2pFXU+t48ITHG1o2WL4NAnvc3IpfVQGhbkr+DlydZ6fKNMMEemd1pRZzRA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@snazzah/davey-win32-x64-msvc@0.1.6':
|
||||||
|
resolution: {integrity: sha512-8tR3o+amQOHJL8QzSwuSCCave+jm3SC1m1OKSh9Coy4wN/XoJN0XQUxqzA7ineSClAMW3yIO0ShFmMlMIXsC0A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@snazzah/davey@0.1.6':
|
||||||
|
resolution: {integrity: sha512-wKJDQ7iobl3rvuQDXLC2yZdpuVxPvMnbyjyPpkcETqPfqNVrdyX9zSdV74dnkpx7aLpINEmKh8ZEIlCIJA2h1w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0':
|
'@standard-schema/spec@1.0.0':
|
||||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
@@ -8812,6 +8905,9 @@ packages:
|
|||||||
discord-api-types@0.38.15:
|
discord-api-types@0.38.15:
|
||||||
resolution: {integrity: sha512-RX3skyRH7p6BlHOW62ztdnIc87+wv4TEJEURMir5k5BbRJ10wK1MCqFEO6USHTol3gkiHLE6wWoHhNQ2pqB4AA==}
|
resolution: {integrity: sha512-RX3skyRH7p6BlHOW62ztdnIc87+wv4TEJEURMir5k5BbRJ10wK1MCqFEO6USHTol3gkiHLE6wWoHhNQ2pqB4AA==}
|
||||||
|
|
||||||
|
discord-api-types@0.38.11:
|
||||||
|
resolution: {integrity: sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw==}
|
||||||
|
|
||||||
dmd@6.2.3:
|
dmd@6.2.3:
|
||||||
resolution: {integrity: sha512-SIEkjrG7cZ9GWZQYk/mH+mWtcRPly/3ibVuXO/tP/MFoWz6KiRK77tSMq6YQBPl7RljPtXPQ/JhxbNuCdi1bNw==}
|
resolution: {integrity: sha512-SIEkjrG7cZ9GWZQYk/mH+mWtcRPly/3ibVuXO/tP/MFoWz6KiRK77tSMq6YQBPl7RljPtXPQ/JhxbNuCdi1bNw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -12095,6 +12191,7 @@ packages:
|
|||||||
|
|
||||||
path-match@1.2.4:
|
path-match@1.2.4:
|
||||||
resolution: {integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==}
|
resolution: {integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==}
|
||||||
|
deprecated: This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions
|
||||||
|
|
||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
@@ -14380,11 +14477,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
yaml@2.7.1:
|
|
||||||
resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
|
|
||||||
engines: {node: '>= 14'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
yaml@2.8.0:
|
yaml@2.8.0:
|
||||||
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
|
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
|
||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
@@ -16711,6 +16803,13 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.9.0
|
'@tybys/wasm-util': 0.9.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@0.2.11':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.4.3
|
||||||
|
'@emnapi/runtime': 1.4.3
|
||||||
|
'@tybys/wasm-util': 0.9.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@neondatabase/serverless@0.9.5':
|
'@neondatabase/serverless@0.9.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/pg': 8.11.6
|
'@types/pg': 8.11.6
|
||||||
@@ -19314,6 +19413,67 @@ snapshots:
|
|||||||
'@smithy/types': 4.2.0
|
'@smithy/types': 4.2.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@snazzah/davey-android-arm-eabi@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-android-arm64@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-darwin-arm64@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-darwin-x64@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-freebsd-x64@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-arm-gnueabihf@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-arm64-gnu@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-arm64-musl@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-x64-gnu@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-linux-x64-musl@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-wasm32-wasi@0.1.6':
|
||||||
|
dependencies:
|
||||||
|
'@napi-rs/wasm-runtime': 0.2.11
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-win32-arm64-msvc@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-win32-ia32-msvc@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey-win32-x64-msvc@0.1.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@snazzah/davey@0.1.6':
|
||||||
|
optionalDependencies:
|
||||||
|
'@snazzah/davey-android-arm-eabi': 0.1.6
|
||||||
|
'@snazzah/davey-android-arm64': 0.1.6
|
||||||
|
'@snazzah/davey-darwin-arm64': 0.1.6
|
||||||
|
'@snazzah/davey-darwin-x64': 0.1.6
|
||||||
|
'@snazzah/davey-freebsd-x64': 0.1.6
|
||||||
|
'@snazzah/davey-linux-arm-gnueabihf': 0.1.6
|
||||||
|
'@snazzah/davey-linux-arm64-gnu': 0.1.6
|
||||||
|
'@snazzah/davey-linux-arm64-musl': 0.1.6
|
||||||
|
'@snazzah/davey-linux-x64-gnu': 0.1.6
|
||||||
|
'@snazzah/davey-linux-x64-musl': 0.1.6
|
||||||
|
'@snazzah/davey-wasm32-wasi': 0.1.6
|
||||||
|
'@snazzah/davey-win32-arm64-msvc': 0.1.6
|
||||||
|
'@snazzah/davey-win32-ia32-msvc': 0.1.6
|
||||||
|
'@snazzah/davey-win32-x64-msvc': 0.1.6
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0': {}
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@storybook/addon-actions@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))':
|
'@storybook/addon-actions@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))':
|
||||||
@@ -23130,6 +23290,8 @@ snapshots:
|
|||||||
|
|
||||||
discord-api-types@0.38.15: {}
|
discord-api-types@0.38.15: {}
|
||||||
|
|
||||||
|
discord-api-types@0.38.11: {}
|
||||||
|
|
||||||
dmd@6.2.3:
|
dmd@6.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-back: 6.2.2
|
array-back: 6.2.2
|
||||||
@@ -27793,15 +27955,6 @@ snapshots:
|
|||||||
|
|
||||||
possible-typed-array-names@1.0.0: {}
|
possible-typed-array-names@1.0.0: {}
|
||||||
|
|
||||||
postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.7.1):
|
|
||||||
dependencies:
|
|
||||||
lilconfig: 3.1.3
|
|
||||||
optionalDependencies:
|
|
||||||
jiti: 2.4.2
|
|
||||||
postcss: 8.5.4
|
|
||||||
tsx: 4.19.2
|
|
||||||
yaml: 2.7.1
|
|
||||||
|
|
||||||
postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.8.0):
|
postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.8.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.3
|
||||||
@@ -29415,35 +29568,6 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
tsup@8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.7.1):
|
|
||||||
dependencies:
|
|
||||||
bundle-require: 5.1.0(esbuild@0.25.5)
|
|
||||||
cac: 6.7.14
|
|
||||||
chokidar: 4.0.3
|
|
||||||
consola: 3.4.2
|
|
||||||
debug: 4.4.1
|
|
||||||
esbuild: 0.25.5
|
|
||||||
fix-dts-default-cjs-exports: 1.0.1
|
|
||||||
joycon: 3.1.1
|
|
||||||
picocolors: 1.1.1
|
|
||||||
postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.7.1)
|
|
||||||
resolve-from: 5.0.0
|
|
||||||
rollup: 4.41.1
|
|
||||||
source-map: 0.8.0-beta.0
|
|
||||||
sucrase: 3.35.0
|
|
||||||
tinyexec: 0.3.2
|
|
||||||
tinyglobby: 0.2.14
|
|
||||||
tree-kill: 1.2.2
|
|
||||||
optionalDependencies:
|
|
||||||
'@microsoft/api-extractor': 7.52.3(@types/node@22.15.26)
|
|
||||||
postcss: 8.5.4
|
|
||||||
typescript: 5.8.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- jiti
|
|
||||||
- supports-color
|
|
||||||
- tsx
|
|
||||||
- yaml
|
|
||||||
|
|
||||||
tsup@8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0):
|
tsup@8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
bundle-require: 5.1.0(esbuild@0.25.5)
|
bundle-require: 5.1.0(esbuild@0.25.5)
|
||||||
@@ -30333,8 +30457,6 @@ snapshots:
|
|||||||
|
|
||||||
yallist@5.0.0: {}
|
yallist@5.0.0: {}
|
||||||
|
|
||||||
yaml@2.7.1: {}
|
|
||||||
|
|
||||||
yaml@2.8.0: {}
|
yaml@2.8.0: {}
|
||||||
|
|
||||||
yargs-parser@20.2.9: {}
|
yargs-parser@20.2.9: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user