@remedia/node 0.0.0-stage → 0.1.0-dev.1
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/LICENSE +21 -0
- package/README.md +144 -2
- package/THIRD_PARTY_LICENSES.txt +15655 -0
- package/THIRD_PARTY_NOTICES.md +126 -0
- package/dist/discord.d.ts +149 -0
- package/dist/discord.js +1168 -0
- package/dist/events.d.ts +21 -0
- package/dist/events.js +28 -0
- package/dist/filters.d.ts +22 -0
- package/dist/filters.js +52 -0
- package/dist/gateway.d.ts +88 -0
- package/dist/gateway.js +100 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +46 -0
- package/dist/input.js +135 -0
- package/dist/lifecycle.d.ts +315 -0
- package/dist/lifecycle.js +109 -0
- package/dist/playback.d.ts +73 -0
- package/dist/playback.js +445 -0
- package/dist/remedia.d.ts +51 -0
- package/dist/remedia.js +150 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +75 -0
- package/native.d.ts +162 -0
- package/native.js +778 -0
- package/package.json +86 -3
package/dist/discord.js
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { RemediaEventEmitter } from './events.js';
|
|
3
|
+
import { isSnowflake } from './gateway.js';
|
|
4
|
+
import { PlaybackHooks, PlaybackSessionEvents as PlaybackEvent, VoiceSessionEvents as VoiceEvent, } from './lifecycle.js';
|
|
5
|
+
import { PlaybackSession } from './playback.js';
|
|
6
|
+
import { FilterControls, validateVolume } from './filters.js';
|
|
7
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 45_000;
|
|
8
|
+
/**
|
|
9
|
+
* One Discord voice session. The caller supplies a Gateway send adapter to `Remedia.create()` and
|
|
10
|
+
* forwards raw dispatch packets to `Remedia.receive()`; this session collects the matching voice
|
|
11
|
+
* credentials and opens the native transport after both dispatches arrive.
|
|
12
|
+
* @typeParam Metadata - Application-owned metadata attached to playback generations.
|
|
13
|
+
*/
|
|
14
|
+
export class DiscordSession extends RemediaEventEmitter {
|
|
15
|
+
/** Opaque Remedia session identity used to correlate events; distinct from the Discord bot user ID. */
|
|
16
|
+
sessionId = randomUUID();
|
|
17
|
+
/** Discord guild snowflake as a decimal string; unique within the owning client. */
|
|
18
|
+
guildId;
|
|
19
|
+
#channelId;
|
|
20
|
+
#selfDeaf;
|
|
21
|
+
#selfMute;
|
|
22
|
+
#closing = false;
|
|
23
|
+
#disposeWork;
|
|
24
|
+
/** Discord bot user snowflake as a decimal string; separate from Remedia session identity. */
|
|
25
|
+
userId;
|
|
26
|
+
#native;
|
|
27
|
+
#gateway;
|
|
28
|
+
#onDispose;
|
|
29
|
+
/** Persistent raw FFmpeg filtergraph controls for this session. */
|
|
30
|
+
filters;
|
|
31
|
+
/** Ordered, awaited local pre-play hooks; listeners are separate from hooks. */
|
|
32
|
+
hooks = new PlaybackHooks();
|
|
33
|
+
#playback;
|
|
34
|
+
#playbackSubscriptions;
|
|
35
|
+
#nativeSession;
|
|
36
|
+
#attempt;
|
|
37
|
+
#voiceState;
|
|
38
|
+
#voiceServer;
|
|
39
|
+
#voiceServerUnavailable = false;
|
|
40
|
+
#nativeTransportGeneration = 0;
|
|
41
|
+
#gatewayEpoch;
|
|
42
|
+
#lastGatewaySequence;
|
|
43
|
+
#nativeWork = Promise.resolve();
|
|
44
|
+
#backgroundCleanup = Promise.resolve();
|
|
45
|
+
#disconnectPromise;
|
|
46
|
+
#connectionState = 'disconnected';
|
|
47
|
+
#connectionError;
|
|
48
|
+
#generation = 0;
|
|
49
|
+
#playbackGeneration = 0;
|
|
50
|
+
#joinRequested = false;
|
|
51
|
+
#disposed = false;
|
|
52
|
+
#filterGraph = null;
|
|
53
|
+
#volume = 1;
|
|
54
|
+
#configurationWork = Promise.resolve();
|
|
55
|
+
#nativeStateTimer;
|
|
56
|
+
/**
|
|
57
|
+
* @internal Constructed by `Remedia.createDiscordSession()`.
|
|
58
|
+
* @param native - Owning native engine instance; its resources must outlive this wrapper.
|
|
59
|
+
* @param gateway - Adapter for the application-owned Discord Gateway connection.
|
|
60
|
+
* @param options - Configuration and lifecycle options for this operation.
|
|
61
|
+
* @param onDispose - Callback invoked when this wrapper releases its owning registration.
|
|
62
|
+
*/
|
|
63
|
+
constructor(native, gateway, options, onDispose) {
|
|
64
|
+
super();
|
|
65
|
+
this.guildId = validateSnowflake('guildId', options.guildId);
|
|
66
|
+
this.#channelId = validateSnowflake('channelId', options.channelId);
|
|
67
|
+
this.userId = validateSnowflake('userId', options.userId);
|
|
68
|
+
validateVolume(options.volume ?? 1);
|
|
69
|
+
if (options.filterGraph !== undefined &&
|
|
70
|
+
options.filterGraph !== null &&
|
|
71
|
+
(typeof options.filterGraph !== 'string' || !options.filterGraph.trim()))
|
|
72
|
+
throw new TypeError('filterGraph must be a non-empty string or null');
|
|
73
|
+
for (const value of [options.selfDeaf, options.selfMute]) {
|
|
74
|
+
if (value !== undefined && typeof value !== 'boolean')
|
|
75
|
+
throw new TypeError('voice flags must be boolean');
|
|
76
|
+
}
|
|
77
|
+
this.#volume = options.volume ?? 1;
|
|
78
|
+
this.#filterGraph = options.filterGraph ?? null;
|
|
79
|
+
this.#selfDeaf = options.selfDeaf ?? true;
|
|
80
|
+
this.#selfMute = options.selfMute ?? false;
|
|
81
|
+
this.#native = native;
|
|
82
|
+
this.#gateway = gateway;
|
|
83
|
+
this.#onDispose = onDispose;
|
|
84
|
+
this.filters = new FilterControls(() => this.#filterGraph, (graph) => this.#replaceFilterGraph(graph), () => this.#replaceFilterGraph(null));
|
|
85
|
+
}
|
|
86
|
+
/** Current requested voice destination; independent of playback identity. */
|
|
87
|
+
get channelId() {
|
|
88
|
+
return this.#channelId;
|
|
89
|
+
}
|
|
90
|
+
/** Discord Gateway self-deaf state; defaults to true and persists across moves and recovery. */
|
|
91
|
+
get selfDeaf() {
|
|
92
|
+
return this.#selfDeaf;
|
|
93
|
+
}
|
|
94
|
+
/** Discord Gateway self-mute state; defaults to false and persists across moves and recovery. */
|
|
95
|
+
get selfMute() {
|
|
96
|
+
return this.#selfMute;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Replace only the voice transport. A newer move supersedes a pending move.
|
|
100
|
+
* @param channelId - Destination Discord voice channel snowflake as a positive decimal string.
|
|
101
|
+
* @returns Resolves when the requested destination connects; rejects on supersession, cancellation, or connection failure.
|
|
102
|
+
*/
|
|
103
|
+
move(channelId) {
|
|
104
|
+
this.#assertNotDisposed();
|
|
105
|
+
validateSnowflake('channelId', channelId);
|
|
106
|
+
if (this.#connectionState === 'disconnecting')
|
|
107
|
+
return Promise.reject(new Error('Discord session is disconnecting'));
|
|
108
|
+
if (channelId === this.channelId)
|
|
109
|
+
return this.connect();
|
|
110
|
+
const previous = this.#attempt;
|
|
111
|
+
if (previous) {
|
|
112
|
+
this.#finishAttempt(previous);
|
|
113
|
+
previous.reject(abortError('Discord voice connection was superseded by move()'));
|
|
114
|
+
}
|
|
115
|
+
this.#channelId = channelId;
|
|
116
|
+
if (!this.#nativeSession)
|
|
117
|
+
return this.connect();
|
|
118
|
+
const attempt = this.#createAttempt(DEFAULT_CONNECT_TIMEOUT_MS, undefined, true);
|
|
119
|
+
attempt.moving = true;
|
|
120
|
+
this.#attempt = attempt;
|
|
121
|
+
this.#voiceState = undefined;
|
|
122
|
+
this.#voiceServer = undefined;
|
|
123
|
+
this.#voiceServerUnavailable = false;
|
|
124
|
+
this.#setConnectionState('connecting');
|
|
125
|
+
this.#suspendCurrentNativeTransport(attempt);
|
|
126
|
+
this.#requestGatewayJoin(attempt);
|
|
127
|
+
return attempt.promise;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Update Discord self-deaf state without rebuilding playback. Unchanged values send nothing.
|
|
131
|
+
* @param enabled - The requested Discord self-state flag.
|
|
132
|
+
* @returns Resolves after storing the flag or sending its Gateway update; rejects if the update fails.
|
|
133
|
+
*/
|
|
134
|
+
setSelfDeaf(enabled) {
|
|
135
|
+
return this.#setVoiceFlag('deaf', enabled);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Update Discord self-mute state without changing audio gain or playback pause. Unchanged values send nothing.
|
|
139
|
+
* @param enabled - The requested Discord self-state flag.
|
|
140
|
+
* @returns Resolves after storing the flag or sending its Gateway update; rejects if the update fails.
|
|
141
|
+
*/
|
|
142
|
+
setSelfMute(enabled) {
|
|
143
|
+
return this.#setVoiceFlag('mute', enabled);
|
|
144
|
+
}
|
|
145
|
+
async #setVoiceFlag(flag, enabled) {
|
|
146
|
+
this.#assertNotDisposed();
|
|
147
|
+
if (typeof enabled !== 'boolean')
|
|
148
|
+
throw new TypeError('voice flag must be boolean');
|
|
149
|
+
await this.#serializeConfiguration(async () => {
|
|
150
|
+
this.#assertNotDisposed();
|
|
151
|
+
if (this.#connectionState === 'disconnecting')
|
|
152
|
+
throw new Error('Discord session is disconnecting');
|
|
153
|
+
if ((flag === 'deaf' ? this.#selfDeaf : this.#selfMute) === enabled)
|
|
154
|
+
return;
|
|
155
|
+
if (this.#joinRequested)
|
|
156
|
+
await this.#gateway.setVoiceChannel(this.guildId, this.channelId, flag === 'deaf' ? enabled : this.#selfDeaf, flag === 'mute' ? enabled : this.#selfMute);
|
|
157
|
+
if (flag === 'deaf')
|
|
158
|
+
this.#selfDeaf = enabled;
|
|
159
|
+
else
|
|
160
|
+
this.#selfMute = enabled;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** Current voice transport state. A connected voice WebSocket may temporarily be reconnecting. */
|
|
164
|
+
get connectionState() {
|
|
165
|
+
if (this.#disposed)
|
|
166
|
+
return 'disconnected';
|
|
167
|
+
if (this.#connectionState === 'connected' && this.#nativeSession) {
|
|
168
|
+
try {
|
|
169
|
+
return this.#native.voiceState(this.#nativeSession.voiceId);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return this.#connectionState;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return this.#connectionState;
|
|
176
|
+
}
|
|
177
|
+
/** Most recent Gateway or native transport failure, if any. */
|
|
178
|
+
get connectionError() {
|
|
179
|
+
if (this.#connectionError)
|
|
180
|
+
return this.#connectionError;
|
|
181
|
+
if (!this.#nativeSession)
|
|
182
|
+
return null;
|
|
183
|
+
try {
|
|
184
|
+
return this.#native.voiceError(this.#nativeSession.voiceId) ?? null;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Current playback lifecycle state. */
|
|
191
|
+
get state() {
|
|
192
|
+
if (this.#playback)
|
|
193
|
+
return this.#playback.state;
|
|
194
|
+
return this.connectionState === 'failed' ? 'failed' : 'idle';
|
|
195
|
+
}
|
|
196
|
+
/** Latest source and output timeline measurements in milliseconds. */
|
|
197
|
+
get position() {
|
|
198
|
+
return (this.#playback?.position ?? {
|
|
199
|
+
mediaMs: null,
|
|
200
|
+
mediaPositionAccuracy: null,
|
|
201
|
+
submittedMs: 0,
|
|
202
|
+
playedMs: null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Source duration in milliseconds, or null when unknown. */
|
|
206
|
+
get durationMs() {
|
|
207
|
+
return this.#playback?.durationMs ?? null;
|
|
208
|
+
}
|
|
209
|
+
/** Most recent playback or voice failure message, or null when no error is available. */
|
|
210
|
+
get error() {
|
|
211
|
+
return this.#playback?.error ?? this.connectionError;
|
|
212
|
+
}
|
|
213
|
+
/** Current normalized linear master gain, from 0.0 (mute) through 2.0. */
|
|
214
|
+
get volume() {
|
|
215
|
+
return this.#volume;
|
|
216
|
+
}
|
|
217
|
+
/** Current native source generation, useful to adapters that preserve event correlation. */
|
|
218
|
+
get generation() {
|
|
219
|
+
return this.#playback?.currentGeneration ?? this.#playbackGeneration;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Set master gain independently of this session's configured filtergraph.
|
|
223
|
+
* @param gain - Finite linear master gain from 0 through 2; 1 leaves amplitude unchanged.
|
|
224
|
+
* @returns Resolves after the gain is stored or applied; rejects for non-finite gain or values outside 0 through 2.
|
|
225
|
+
* @throws {RangeError} If gain is non-finite or outside the inclusive range 0 through 2.
|
|
226
|
+
*/
|
|
227
|
+
async setVolume(gain) {
|
|
228
|
+
this.#assertNotDisposed();
|
|
229
|
+
validateVolume(gain);
|
|
230
|
+
await this.#serializeConfiguration(async () => {
|
|
231
|
+
if (this.#connectionState === 'disconnecting') {
|
|
232
|
+
throw new Error('Discord voice session is disconnecting');
|
|
233
|
+
}
|
|
234
|
+
const previousVolume = this.#volume;
|
|
235
|
+
if (previousVolume === gain)
|
|
236
|
+
return;
|
|
237
|
+
if (this.#playback) {
|
|
238
|
+
await this.#playback.setVolume(gain);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
this.emit(PlaybackEvent.volumeChanged, {
|
|
242
|
+
sessionId: this.sessionId,
|
|
243
|
+
guildId: this.guildId,
|
|
244
|
+
nodeId: null,
|
|
245
|
+
generation: this.#playbackGeneration,
|
|
246
|
+
previousVolume,
|
|
247
|
+
volume: gain,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
this.#volume = gain;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Request a Gateway voice join and wait until both voice dispatches have arrived and the native
|
|
255
|
+
* voice transport has completed its DAVE setup. Repeated calls while connecting share a promise.
|
|
256
|
+
* @param options - Optional cancellation signal and positive timeout in milliseconds; defaults to 45000.
|
|
257
|
+
* @returns Resolves when connected; rejects on timeout, cancellation, session loss, or transport failure.
|
|
258
|
+
*/
|
|
259
|
+
connect(options = {}) {
|
|
260
|
+
this.#assertNotDisposed();
|
|
261
|
+
if (this.#attempt)
|
|
262
|
+
return this.#attempt.promise;
|
|
263
|
+
const currentState = this.connectionState;
|
|
264
|
+
if (currentState === 'connected' || currentState === 'reconnecting')
|
|
265
|
+
return Promise.resolve();
|
|
266
|
+
if (this.#connectionState === 'disconnecting') {
|
|
267
|
+
return Promise.reject(new Error('Discord voice session is disconnecting'));
|
|
268
|
+
}
|
|
269
|
+
if (options.signal?.aborted) {
|
|
270
|
+
return Promise.reject(abortError('Discord voice connection was cancelled'));
|
|
271
|
+
}
|
|
272
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
273
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
274
|
+
return Promise.reject(new RangeError('timeoutMs must be a positive finite number'));
|
|
275
|
+
}
|
|
276
|
+
const attempt = this.#createAttempt(timeoutMs, options.signal, false);
|
|
277
|
+
this.#attempt = attempt;
|
|
278
|
+
this.#voiceState = undefined;
|
|
279
|
+
this.#voiceServer = undefined;
|
|
280
|
+
this.#voiceServerUnavailable = false;
|
|
281
|
+
this.#connectionError = undefined;
|
|
282
|
+
this.#setConnectionState('connecting');
|
|
283
|
+
this.#joinRequested = true;
|
|
284
|
+
if (attempt.signal) {
|
|
285
|
+
attempt.abortListener = () => {
|
|
286
|
+
this.#failAttempt(attempt, abortError('Discord voice connection was cancelled'), true);
|
|
287
|
+
};
|
|
288
|
+
attempt.signal.addEventListener('abort', attempt.abortListener, { once: true });
|
|
289
|
+
}
|
|
290
|
+
this.#requestGatewayJoin(attempt);
|
|
291
|
+
return attempt.promise;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Begin playback on this connected voice session. The decoder and Opus encoder remain in core;
|
|
295
|
+
* this object only associates their output with the native Discord transport.
|
|
296
|
+
* @param source - Encoded media input; Buffer and caller-owned binary Readable inputs require embedded mode.
|
|
297
|
+
* @param options - Optional metadata for this generation and a cancellation signal.
|
|
298
|
+
* @returns Selected stream metadata once startup succeeds; rejects if hooks, input opening, or playback startup fail.
|
|
299
|
+
*/
|
|
300
|
+
async play(source, options = {}) {
|
|
301
|
+
await this.#configurationWork;
|
|
302
|
+
return this.#requirePlayback()
|
|
303
|
+
.play(source, options)
|
|
304
|
+
.then((info) => {
|
|
305
|
+
this.#playbackGeneration = this.#playback?.currentGeneration ?? this.#playbackGeneration;
|
|
306
|
+
return info;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Pause the current playback without replacing its decoder or source generation.
|
|
311
|
+
* @returns Resolves when the backend accepts the pause request.
|
|
312
|
+
*/
|
|
313
|
+
pause() {
|
|
314
|
+
return this.#requireExistingPlayback().pause();
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Resume paused playback on its existing source generation.
|
|
318
|
+
* @returns Resolves when the backend accepts the resume request.
|
|
319
|
+
*/
|
|
320
|
+
resume() {
|
|
321
|
+
return this.#requireExistingPlayback().resume();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Seek on the original media timeline; non-seekable and one-shot sources may reject.
|
|
325
|
+
* @param positionMs - Non-negative finite position on the original media timeline, in milliseconds.
|
|
326
|
+
* @returns Resolves after the backend accepts the seek; rejects for invalid positions or unsupported inputs.
|
|
327
|
+
*/
|
|
328
|
+
seek(positionMs) {
|
|
329
|
+
return this.#requireExistingPlayback().seek(positionMs);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Stop the active playback generation and release its input without leaving the voice channel.
|
|
333
|
+
* @returns Resolves after the backend stops the active source.
|
|
334
|
+
*/
|
|
335
|
+
stop() {
|
|
336
|
+
return this.#requireExistingPlayback().stop();
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Wait for the current playback generation to reach a terminal state.
|
|
340
|
+
* @returns The terminal playback state for the generation captured by this wait.
|
|
341
|
+
*/
|
|
342
|
+
wait() {
|
|
343
|
+
return this.#requireExistingPlayback().wait();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Leave the Discord channel through Gateway OP 4 and close the native transport. This does not
|
|
347
|
+
* dispose the wrapper, so `connect()` can be called again with fresh Gateway dispatches.
|
|
348
|
+
* @returns Resolves after cleanup; concurrent disconnects share cleanup work.
|
|
349
|
+
*/
|
|
350
|
+
disconnect() {
|
|
351
|
+
if (this.#disposed)
|
|
352
|
+
return Promise.resolve();
|
|
353
|
+
if (this.#disconnectPromise)
|
|
354
|
+
return this.#disconnectPromise;
|
|
355
|
+
const disconnect = this.#disconnect();
|
|
356
|
+
this.#disconnectPromise = disconnect.finally(() => {
|
|
357
|
+
this.#disconnectPromise = undefined;
|
|
358
|
+
});
|
|
359
|
+
return this.#disconnectPromise;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Disconnect and unregister this session.
|
|
363
|
+
* @returns Resolves after disposal; rejects if resource cleanup fails.
|
|
364
|
+
*/
|
|
365
|
+
dispose() {
|
|
366
|
+
if (this.#disposeWork)
|
|
367
|
+
return this.#disposeWork;
|
|
368
|
+
this.#disposeWork = this.#dispose();
|
|
369
|
+
return this.#disposeWork;
|
|
370
|
+
}
|
|
371
|
+
async #dispose() {
|
|
372
|
+
if (this.#disposed)
|
|
373
|
+
return;
|
|
374
|
+
this.#closing = true;
|
|
375
|
+
try {
|
|
376
|
+
await this.disconnect();
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
this.#disposed = true;
|
|
380
|
+
this.emit(PlaybackEvent.sessionDisposed, {
|
|
381
|
+
sessionId: this.sessionId,
|
|
382
|
+
guildId: this.guildId,
|
|
383
|
+
nodeId: null,
|
|
384
|
+
generation: this.#playbackGeneration,
|
|
385
|
+
});
|
|
386
|
+
this.#onDispose();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* @internal Routes a parsed raw Gateway dispatch to this guild's active voice lifecycle.
|
|
391
|
+
* @param event - Event name identifying the typed notification or Gateway voice dispatch.
|
|
392
|
+
* @param data - Validated guild-scoped Gateway dispatch data.
|
|
393
|
+
* @param sequence - Gateway dispatch sequence, when supplied by the host application.
|
|
394
|
+
* @param epoch - Gateway READY epoch used to reset stale-sequence tracking.
|
|
395
|
+
*/
|
|
396
|
+
receiveGatewayDispatch(event, data, sequence, epoch) {
|
|
397
|
+
if (this.#disposed || data.guild_id !== this.guildId)
|
|
398
|
+
return;
|
|
399
|
+
if (epoch !== undefined && epoch !== this.#gatewayEpoch) {
|
|
400
|
+
this.#gatewayEpoch = epoch;
|
|
401
|
+
this.#lastGatewaySequence = undefined;
|
|
402
|
+
if (this.#attempt)
|
|
403
|
+
this.#attempt.sequenceFloor = undefined;
|
|
404
|
+
}
|
|
405
|
+
if (sequence !== undefined &&
|
|
406
|
+
this.#lastGatewaySequence !== undefined &&
|
|
407
|
+
sequence <= this.#lastGatewaySequence) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (sequence !== undefined)
|
|
411
|
+
this.#lastGatewaySequence = sequence;
|
|
412
|
+
const attempt = this.#attempt;
|
|
413
|
+
if (attempt &&
|
|
414
|
+
sequence !== undefined &&
|
|
415
|
+
attempt.sequenceFloor !== undefined &&
|
|
416
|
+
sequence <= attempt.sequenceFloor) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (event === 'VOICE_STATE_UPDATE') {
|
|
420
|
+
this.#receiveVoiceState(data);
|
|
421
|
+
}
|
|
422
|
+
else if (event === 'VOICE_SERVER_UPDATE') {
|
|
423
|
+
this.#receiveVoiceServer(data);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async #disconnect() {
|
|
427
|
+
const attempt = this.#attempt;
|
|
428
|
+
const shouldLeaveGateway = this.#joinRequested || this.#nativeSession !== undefined;
|
|
429
|
+
this.#joinRequested = false;
|
|
430
|
+
this.#generation += 1;
|
|
431
|
+
this.#stopNativeStateMonitor();
|
|
432
|
+
this.#voiceServerUnavailable = false;
|
|
433
|
+
this.#invalidateNativeTransport();
|
|
434
|
+
this.#setConnectionState('disconnecting');
|
|
435
|
+
this.#voiceState = undefined;
|
|
436
|
+
this.#voiceServer = undefined;
|
|
437
|
+
if (attempt) {
|
|
438
|
+
this.#finishAttempt(attempt);
|
|
439
|
+
attempt.reject(abortError('Discord voice connection was cancelled by disconnect()'));
|
|
440
|
+
}
|
|
441
|
+
const errors = [];
|
|
442
|
+
if (shouldLeaveGateway) {
|
|
443
|
+
try {
|
|
444
|
+
await this.#gateway.setVoiceChannel(this.guildId, null);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
errors.push(asError(error));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// A native connection attempt cannot be interrupted inside FFmpeg/transport setup. Its own
|
|
451
|
+
// network operations have finite timeouts; wait for its generation check to dispose late
|
|
452
|
+
// results before returning from disconnect.
|
|
453
|
+
await this.#nativeWork;
|
|
454
|
+
await this.#backgroundCleanup;
|
|
455
|
+
try {
|
|
456
|
+
await this.#closeCurrentNativeSession();
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
errors.push(asError(error));
|
|
460
|
+
}
|
|
461
|
+
this.#joinRequested = false;
|
|
462
|
+
this.#setConnectionState('disconnected');
|
|
463
|
+
if (errors.length > 0) {
|
|
464
|
+
this.#connectionError = errors.map((error) => error.message).join('; ');
|
|
465
|
+
throw new AggregateError(errors, 'Discord voice disconnection failed');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
#receiveVoiceState(data) {
|
|
469
|
+
if (data.user_id !== this.userId ||
|
|
470
|
+
!this.#joinRequested ||
|
|
471
|
+
this.#connectionState === 'disconnecting') {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (data.channel_id === null) {
|
|
475
|
+
if (this.#attempt?.moving)
|
|
476
|
+
return;
|
|
477
|
+
if (this.#connectionState === 'connected' || this.#connectionState === 'reconnecting') {
|
|
478
|
+
this.#handleGatewayDisconnect();
|
|
479
|
+
}
|
|
480
|
+
// A null channel can be a delayed state from a previous leave while a new join is pending.
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (data.channel_id !== this.channelId || typeof data.session_id !== 'string')
|
|
484
|
+
return;
|
|
485
|
+
if (data.session_id.length === 0)
|
|
486
|
+
return;
|
|
487
|
+
const previousSessionId = this.#voiceState?.sessionId;
|
|
488
|
+
if (previousSessionId === data.session_id)
|
|
489
|
+
return;
|
|
490
|
+
const refreshRequired = !this.#attempt &&
|
|
491
|
+
(this.#connectionState === 'connected' ||
|
|
492
|
+
this.#connectionState === 'reconnecting' ||
|
|
493
|
+
this.#connectionState === 'disconnected' ||
|
|
494
|
+
this.#connectionState === 'failed');
|
|
495
|
+
this.#voiceState = { sessionId: data.session_id };
|
|
496
|
+
if (previousSessionId !== undefined) {
|
|
497
|
+
this.#voiceServer = undefined;
|
|
498
|
+
this.#voiceServerUnavailable = true;
|
|
499
|
+
}
|
|
500
|
+
if (refreshRequired) {
|
|
501
|
+
this.#beginCredentialRefresh();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.#credentialsChanged();
|
|
505
|
+
}
|
|
506
|
+
#receiveVoiceServer(data) {
|
|
507
|
+
if (!this.#joinRequested || this.#connectionState === 'disconnecting')
|
|
508
|
+
return;
|
|
509
|
+
if (data.endpoint === null) {
|
|
510
|
+
this.#voiceServerUnavailable = true;
|
|
511
|
+
if (!this.#attempt &&
|
|
512
|
+
['connected', 'reconnecting', 'disconnected', 'failed'].includes(this.#connectionState)) {
|
|
513
|
+
this.#beginCredentialRefresh();
|
|
514
|
+
}
|
|
515
|
+
else if (this.#attempt && !this.#attempt.internal) {
|
|
516
|
+
this.#failGateway(new Error('Discord did not provide a voice server endpoint'));
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (typeof data.token !== 'string' || typeof data.endpoint !== 'string')
|
|
521
|
+
return;
|
|
522
|
+
if (data.token.length === 0 || data.endpoint.length === 0)
|
|
523
|
+
return;
|
|
524
|
+
const sameServer = this.#voiceServer?.token === data.token && this.#voiceServer.endpoint === data.endpoint;
|
|
525
|
+
const currentState = this.connectionState;
|
|
526
|
+
const refreshRequired = !this.#attempt &&
|
|
527
|
+
currentState !== 'connecting' &&
|
|
528
|
+
currentState !== 'negotiatingDave' &&
|
|
529
|
+
(this.#voiceServerUnavailable || currentState !== 'connected' || !sameServer);
|
|
530
|
+
this.#voiceServerUnavailable = false;
|
|
531
|
+
if (sameServer && !refreshRequired) {
|
|
532
|
+
this.#credentialsChanged();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.#voiceServer = { token: data.token, endpoint: data.endpoint };
|
|
536
|
+
if (refreshRequired) {
|
|
537
|
+
this.#beginCredentialRefresh();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.#credentialsChanged();
|
|
541
|
+
}
|
|
542
|
+
#credentialsChanged() {
|
|
543
|
+
const attempt = this.#attempt;
|
|
544
|
+
if (!attempt ||
|
|
545
|
+
!this.#voiceState ||
|
|
546
|
+
!this.#voiceServer ||
|
|
547
|
+
this.#voiceServerUnavailable ||
|
|
548
|
+
!attempt.requestSent)
|
|
549
|
+
return;
|
|
550
|
+
this.#tryStartNativeConnection(attempt);
|
|
551
|
+
}
|
|
552
|
+
#createAttempt(timeoutMs, signal, internal) {
|
|
553
|
+
let resolve;
|
|
554
|
+
let reject;
|
|
555
|
+
const promise = new Promise((res, rej) => {
|
|
556
|
+
resolve = res;
|
|
557
|
+
reject = rej;
|
|
558
|
+
});
|
|
559
|
+
void promise.catch(() => { });
|
|
560
|
+
let attempt;
|
|
561
|
+
const onTimeout = () => {
|
|
562
|
+
const error = new Error(`Discord voice connection timed out after ${timeoutMs} ms`);
|
|
563
|
+
if (this.#retryCredentialRefresh(attempt, error)) {
|
|
564
|
+
attempt.timer = setTimeout(onTimeout, timeoutMs);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
this.#failAttempt(attempt, error, true);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
this.#generation += 1;
|
|
571
|
+
attempt = {
|
|
572
|
+
generation: this.#generation,
|
|
573
|
+
sequenceFloor: this.#gateway.lastSequence,
|
|
574
|
+
promise,
|
|
575
|
+
resolve,
|
|
576
|
+
reject,
|
|
577
|
+
timer: setTimeout(onTimeout, timeoutMs),
|
|
578
|
+
signal,
|
|
579
|
+
abortListener: undefined,
|
|
580
|
+
requestSent: false,
|
|
581
|
+
settled: false,
|
|
582
|
+
internal,
|
|
583
|
+
retryCount: 0,
|
|
584
|
+
transportGeneration: undefined,
|
|
585
|
+
};
|
|
586
|
+
return attempt;
|
|
587
|
+
}
|
|
588
|
+
#requestGatewayJoin(attempt) {
|
|
589
|
+
let sendPromise;
|
|
590
|
+
try {
|
|
591
|
+
sendPromise = this.#gateway.setVoiceChannel(this.guildId, this.channelId, this.#selfDeaf, this.#selfMute);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
if (!this.#retryCredentialRefresh(attempt, asError(error))) {
|
|
595
|
+
this.#failAttempt(attempt, asError(error), true);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
void Promise.resolve(sendPromise)
|
|
600
|
+
.then(() => {
|
|
601
|
+
if (this.#attempt !== attempt)
|
|
602
|
+
return;
|
|
603
|
+
attempt.requestSent = true;
|
|
604
|
+
this.#tryStartNativeConnection(attempt);
|
|
605
|
+
})
|
|
606
|
+
.catch((error) => {
|
|
607
|
+
if (!this.#retryCredentialRefresh(attempt, asError(error))) {
|
|
608
|
+
this.#failAttempt(attempt, asError(error), true);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
#beginCredentialRefresh(requireFreshServer = false) {
|
|
613
|
+
if (this.#attempt || this.#disposed)
|
|
614
|
+
return;
|
|
615
|
+
if (requireFreshServer) {
|
|
616
|
+
this.#voiceServer = undefined;
|
|
617
|
+
this.#voiceServerUnavailable = true;
|
|
618
|
+
}
|
|
619
|
+
const attempt = this.#createAttempt(DEFAULT_CONNECT_TIMEOUT_MS, undefined, true);
|
|
620
|
+
// This internal attempt may have no direct caller waiting on its Promise.
|
|
621
|
+
void attempt.promise.catch(() => { });
|
|
622
|
+
this.#attempt = attempt;
|
|
623
|
+
this.#setConnectionState('reconnecting');
|
|
624
|
+
this.#connectionError = undefined;
|
|
625
|
+
this.#joinRequested = true;
|
|
626
|
+
this.#suspendCurrentNativeTransport(attempt);
|
|
627
|
+
this.#requestGatewayJoin(attempt);
|
|
628
|
+
}
|
|
629
|
+
#tryStartNativeConnection(attempt) {
|
|
630
|
+
if (this.#attempt !== attempt ||
|
|
631
|
+
!attempt.requestSent ||
|
|
632
|
+
!this.#voiceState ||
|
|
633
|
+
!this.#voiceServer ||
|
|
634
|
+
this.#voiceServerUnavailable) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
this.#scheduleNativeConnection(attempt);
|
|
638
|
+
}
|
|
639
|
+
#scheduleNativeConnection(attempt) {
|
|
640
|
+
if (!this.#voiceState || !this.#voiceServer)
|
|
641
|
+
return;
|
|
642
|
+
const credentials = {
|
|
643
|
+
...this.#voiceState,
|
|
644
|
+
...this.#voiceServer,
|
|
645
|
+
};
|
|
646
|
+
const generation = ++this.#generation;
|
|
647
|
+
if (attempt)
|
|
648
|
+
attempt.generation = generation;
|
|
649
|
+
const existing = this.#nativeSession;
|
|
650
|
+
const transportGeneration = existing ? this.#nextNativeTransportGeneration() : undefined;
|
|
651
|
+
if (existing && transportGeneration !== undefined) {
|
|
652
|
+
if (attempt)
|
|
653
|
+
attempt.transportGeneration = transportGeneration;
|
|
654
|
+
try {
|
|
655
|
+
this.#native.invalidateVoiceRecovery(existing.voiceId, transportGeneration);
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
this.#nativeConnectionFailed(asError(error), generation, attempt);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.#setConnectionState(this.#nativeSession ? 'reconnecting' : 'negotiatingDave');
|
|
663
|
+
const work = this.#nativeWork.then(async () => {
|
|
664
|
+
if (!this.#isCurrent(generation, attempt))
|
|
665
|
+
return;
|
|
666
|
+
await this.#backgroundCleanup;
|
|
667
|
+
if (!this.#isCurrent(generation, attempt))
|
|
668
|
+
return;
|
|
669
|
+
const options = {
|
|
670
|
+
guildId: this.guildId,
|
|
671
|
+
channelId: this.channelId,
|
|
672
|
+
userId: this.userId,
|
|
673
|
+
sessionId: credentials.sessionId,
|
|
674
|
+
token: credentials.token,
|
|
675
|
+
endpoint: credentials.endpoint,
|
|
676
|
+
};
|
|
677
|
+
let playback;
|
|
678
|
+
if (existing) {
|
|
679
|
+
if (transportGeneration === undefined) {
|
|
680
|
+
throw new Error('Discord voice replacement generation is unavailable');
|
|
681
|
+
}
|
|
682
|
+
await this.#native.replaceVoice(existing.voiceId, transportGeneration, options);
|
|
683
|
+
if (!this.#isCurrent(generation, attempt))
|
|
684
|
+
return;
|
|
685
|
+
playback = this.#playback;
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
const nativeSession = await this.#native.connectVoice(options);
|
|
689
|
+
if (!this.#isCurrent(generation, attempt)) {
|
|
690
|
+
await this.#releaseNativeSession(nativeSession, undefined);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
this.#nativeSession = nativeSession;
|
|
694
|
+
const createdPlayback = new PlaybackSession(this.#native, nativeSession.playbackId, undefined, this.guildId, this.hooks);
|
|
695
|
+
playback = createdPlayback;
|
|
696
|
+
this.#playback = createdPlayback;
|
|
697
|
+
await this.#serializeConfiguration(async () => {
|
|
698
|
+
if (this.#playback !== createdPlayback)
|
|
699
|
+
return;
|
|
700
|
+
if (this.#filterGraph !== null)
|
|
701
|
+
await createdPlayback.filters.set(this.#filterGraph);
|
|
702
|
+
if (this.#volume !== 1)
|
|
703
|
+
await createdPlayback.setVolume(this.#volume);
|
|
704
|
+
});
|
|
705
|
+
if (!this.#isCurrent(generation, attempt))
|
|
706
|
+
return;
|
|
707
|
+
this.#forwardPlaybackEvents(createdPlayback);
|
|
708
|
+
}
|
|
709
|
+
this.#connectionError = undefined;
|
|
710
|
+
this.#setConnectionState('connected');
|
|
711
|
+
this.#voiceState = { sessionId: credentials.sessionId };
|
|
712
|
+
this.#voiceServer = { token: credentials.token, endpoint: credentials.endpoint };
|
|
713
|
+
this.#voiceServerUnavailable = false;
|
|
714
|
+
this.#startNativeStateMonitor();
|
|
715
|
+
this.#playbackGeneration = playback?.currentGeneration ?? this.#playbackGeneration;
|
|
716
|
+
if (attempt && this.#attempt === attempt) {
|
|
717
|
+
this.#finishAttempt(attempt);
|
|
718
|
+
attempt.resolve();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
this.#nativeWork = work.catch((error) => {
|
|
722
|
+
this.#nativeConnectionFailed(asError(error), generation, attempt);
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
#suspendCurrentNativeTransport(attempt) {
|
|
726
|
+
const nativeSession = this.#nativeSession;
|
|
727
|
+
if (!nativeSession)
|
|
728
|
+
return;
|
|
729
|
+
const generation = this.#nextNativeTransportGeneration();
|
|
730
|
+
attempt.transportGeneration = generation;
|
|
731
|
+
try {
|
|
732
|
+
this.#native.invalidateVoiceRecovery(nativeSession.voiceId, generation);
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
this.#nativeConnectionFailed(asError(error), attempt.generation, attempt);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const work = this.#nativeWork.then(() => this.#native.suspendVoice(nativeSession.voiceId, generation));
|
|
739
|
+
this.#nativeWork = work.catch((error) => {
|
|
740
|
+
if (this.#isCurrent(attempt.generation, attempt)) {
|
|
741
|
+
this.#connectionError = asError(error).message;
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
#startNativeStateMonitor() {
|
|
746
|
+
this.#stopNativeStateMonitor();
|
|
747
|
+
this.#nativeStateTimer = setInterval(() => {
|
|
748
|
+
if (this.#disposed || !this.#nativeSession || this.#attempt || !this.#joinRequested)
|
|
749
|
+
return;
|
|
750
|
+
try {
|
|
751
|
+
const state = this.#native.voiceState(this.#nativeSession.voiceId);
|
|
752
|
+
if (state === 'failed' || state === 'disconnected') {
|
|
753
|
+
this.#beginCredentialRefresh(true);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// Explicit close/disposal owns cleanup if the native entry disappeared.
|
|
758
|
+
}
|
|
759
|
+
}, 250);
|
|
760
|
+
this.#nativeStateTimer.unref?.();
|
|
761
|
+
}
|
|
762
|
+
#stopNativeStateMonitor() {
|
|
763
|
+
if (!this.#nativeStateTimer)
|
|
764
|
+
return;
|
|
765
|
+
clearInterval(this.#nativeStateTimer);
|
|
766
|
+
this.#nativeStateTimer = undefined;
|
|
767
|
+
}
|
|
768
|
+
#invalidateNativeTransport() {
|
|
769
|
+
const nativeSession = this.#nativeSession;
|
|
770
|
+
if (!nativeSession)
|
|
771
|
+
return;
|
|
772
|
+
try {
|
|
773
|
+
this.#native.invalidateVoiceRecovery(nativeSession.voiceId, this.#nextNativeTransportGeneration());
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Explicit disconnect and disposal remain authoritative while native cleanup runs.
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
#nextNativeTransportGeneration() {
|
|
780
|
+
this.#nativeTransportGeneration =
|
|
781
|
+
this.#nativeTransportGeneration >= 0xffff_fffe ? 1 : this.#nativeTransportGeneration + 1;
|
|
782
|
+
return this.#nativeTransportGeneration;
|
|
783
|
+
}
|
|
784
|
+
#isCurrent(generation, attempt) {
|
|
785
|
+
return (!this.#disposed &&
|
|
786
|
+
this.#generation === generation &&
|
|
787
|
+
(attempt ? this.#attempt === attempt : this.#attempt === undefined));
|
|
788
|
+
}
|
|
789
|
+
#nativeConnectionFailed(error, generation, attempt) {
|
|
790
|
+
if (!this.#isCurrent(generation, attempt))
|
|
791
|
+
return;
|
|
792
|
+
if (attempt && this.#retryCredentialRefresh(attempt, error))
|
|
793
|
+
return;
|
|
794
|
+
this.#connectionError = error.message;
|
|
795
|
+
this.#stopNativeStateMonitor();
|
|
796
|
+
this.#setConnectionState('failed');
|
|
797
|
+
const pending = this.#attempt;
|
|
798
|
+
if (pending) {
|
|
799
|
+
this.#finishAttempt(pending);
|
|
800
|
+
pending.reject(error);
|
|
801
|
+
}
|
|
802
|
+
const shouldLeaveGateway = this.#joinRequested;
|
|
803
|
+
this.#joinRequested = false;
|
|
804
|
+
this.#generation += 1;
|
|
805
|
+
if (!attempt?.internal) {
|
|
806
|
+
this.#voiceState = undefined;
|
|
807
|
+
this.#voiceServer = undefined;
|
|
808
|
+
this.#voiceServerUnavailable = false;
|
|
809
|
+
}
|
|
810
|
+
if (attempt?.moving) {
|
|
811
|
+
// Keep playback suspended for an explicit retry or disconnect.
|
|
812
|
+
}
|
|
813
|
+
else if (attempt?.internal && this.#nativeSession) {
|
|
814
|
+
try {
|
|
815
|
+
this.#native.failVoiceRecovery(this.#nativeSession.voiceId, 'Discord voice recovery failed');
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
// A concurrently disposed native instance already released the router.
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
this.#queueCurrentNativeCleanup();
|
|
823
|
+
}
|
|
824
|
+
if (shouldLeaveGateway)
|
|
825
|
+
void this.#sendGatewayLeave();
|
|
826
|
+
}
|
|
827
|
+
#failGateway(error) {
|
|
828
|
+
const attempt = this.#attempt;
|
|
829
|
+
if (attempt) {
|
|
830
|
+
if (!this.#retryCredentialRefresh(attempt, error))
|
|
831
|
+
this.#failAttempt(attempt, error, true);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (this.#connectionState === 'connected' || this.#connectionState === 'reconnecting') {
|
|
835
|
+
this.#nativeConnectionFailed(error, this.#generation, undefined);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
#failAttempt(attempt, error, leaveGateway) {
|
|
839
|
+
if (this.#attempt !== attempt)
|
|
840
|
+
return;
|
|
841
|
+
if (attempt.moving)
|
|
842
|
+
this.#suspendCurrentNativeTransport(attempt);
|
|
843
|
+
this.#generation += 1;
|
|
844
|
+
this.#finishAttempt(attempt);
|
|
845
|
+
this.#connectionError = error.name === 'AbortError' ? undefined : error.message;
|
|
846
|
+
this.#stopNativeStateMonitor();
|
|
847
|
+
this.#setConnectionState(error.name === 'AbortError' ? 'disconnected' : 'failed');
|
|
848
|
+
if (!attempt.internal) {
|
|
849
|
+
this.#voiceState = undefined;
|
|
850
|
+
this.#voiceServer = undefined;
|
|
851
|
+
this.#voiceServerUnavailable = false;
|
|
852
|
+
}
|
|
853
|
+
if (attempt.moving) {
|
|
854
|
+
// Keep playback suspended for an explicit retry or disconnect.
|
|
855
|
+
}
|
|
856
|
+
else if (attempt.internal && this.#nativeSession) {
|
|
857
|
+
try {
|
|
858
|
+
this.#native.failVoiceRecovery(this.#nativeSession.voiceId, 'Discord voice recovery timed out');
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// The owning Remedia instance may already be closing.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
this.#queueCurrentNativeCleanup();
|
|
866
|
+
}
|
|
867
|
+
const shouldLeaveGateway = leaveGateway && this.#joinRequested;
|
|
868
|
+
this.#joinRequested = false;
|
|
869
|
+
attempt.reject(error);
|
|
870
|
+
if (shouldLeaveGateway)
|
|
871
|
+
void this.#sendGatewayLeave();
|
|
872
|
+
}
|
|
873
|
+
#retryCredentialRefresh(attempt, _error) {
|
|
874
|
+
if (!attempt.internal ||
|
|
875
|
+
attempt.moving ||
|
|
876
|
+
this.#attempt !== attempt ||
|
|
877
|
+
attempt.settled ||
|
|
878
|
+
attempt.retryCount >= 2 ||
|
|
879
|
+
this.#disposed) {
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
attempt.retryCount += 1;
|
|
883
|
+
this.#generation += 1;
|
|
884
|
+
attempt.generation = this.#generation;
|
|
885
|
+
attempt.sequenceFloor = this.#gateway.lastSequence;
|
|
886
|
+
attempt.requestSent = false;
|
|
887
|
+
this.#voiceServer = undefined;
|
|
888
|
+
this.#voiceServerUnavailable = true;
|
|
889
|
+
this.#setConnectionState('reconnecting');
|
|
890
|
+
this.#suspendCurrentNativeTransport(attempt);
|
|
891
|
+
this.#requestGatewayJoin(attempt);
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
#finishAttempt(attempt) {
|
|
895
|
+
if (attempt.settled)
|
|
896
|
+
return;
|
|
897
|
+
attempt.settled = true;
|
|
898
|
+
clearTimeout(attempt.timer);
|
|
899
|
+
if (attempt.signal && attempt.abortListener) {
|
|
900
|
+
attempt.signal.removeEventListener('abort', attempt.abortListener);
|
|
901
|
+
}
|
|
902
|
+
if (this.#attempt === attempt)
|
|
903
|
+
this.#attempt = undefined;
|
|
904
|
+
}
|
|
905
|
+
async #handleGatewayDisconnect() {
|
|
906
|
+
const attempt = this.#attempt;
|
|
907
|
+
this.#generation += 1;
|
|
908
|
+
this.#setConnectionState('disconnecting');
|
|
909
|
+
this.#invalidateNativeTransport();
|
|
910
|
+
if (attempt) {
|
|
911
|
+
this.#finishAttempt(attempt);
|
|
912
|
+
attempt.reject(new Error('Discord Gateway reported that the bot left the voice channel'));
|
|
913
|
+
}
|
|
914
|
+
this.#joinRequested = false;
|
|
915
|
+
this.#voiceState = undefined;
|
|
916
|
+
this.#voiceServer = undefined;
|
|
917
|
+
this.#voiceServerUnavailable = false;
|
|
918
|
+
this.#stopNativeStateMonitor();
|
|
919
|
+
try {
|
|
920
|
+
await this.#nativeWork;
|
|
921
|
+
await this.#backgroundCleanup;
|
|
922
|
+
await this.#closeCurrentNativeSession();
|
|
923
|
+
this.#setConnectionState('disconnected');
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
this.#connectionError = asError(error).message;
|
|
927
|
+
this.#setConnectionState('failed');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
async #closeCurrentNativeSession() {
|
|
931
|
+
await this.#configurationWork;
|
|
932
|
+
const nativeSession = this.#nativeSession;
|
|
933
|
+
if (!nativeSession)
|
|
934
|
+
return;
|
|
935
|
+
this.#nativeSession = undefined;
|
|
936
|
+
const playback = this.#playback;
|
|
937
|
+
this.#playback = undefined;
|
|
938
|
+
await this.#releaseNativeSession(nativeSession, playback);
|
|
939
|
+
}
|
|
940
|
+
#queueCurrentNativeCleanup() {
|
|
941
|
+
const nativeSession = this.#nativeSession;
|
|
942
|
+
if (!nativeSession)
|
|
943
|
+
return;
|
|
944
|
+
this.#nativeSession = undefined;
|
|
945
|
+
const playback = this.#playback;
|
|
946
|
+
this.#playback = undefined;
|
|
947
|
+
this.#backgroundCleanup = this.#backgroundCleanup
|
|
948
|
+
.then(() => this.#releaseNativeSession(nativeSession, playback))
|
|
949
|
+
.catch((error) => {
|
|
950
|
+
this.#connectionError ??= asError(error).message;
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
async #releaseNativeSession(nativeSession, playback) {
|
|
954
|
+
const errors = [];
|
|
955
|
+
try {
|
|
956
|
+
await playback?.releaseInput();
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
errors.push(asError(error));
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
await this.#native.disconnectVoice(nativeSession.voiceId);
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
errors.push(asError(error));
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
this.#native.releaseVoiceSession(nativeSession.voiceId);
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
errors.push(asError(error));
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
if (playback)
|
|
975
|
+
this.#detachPlaybackEvents(playback);
|
|
976
|
+
}
|
|
977
|
+
if (errors.length > 0) {
|
|
978
|
+
throw new AggregateError(errors, 'could not release native Discord voice session');
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async #sendGatewayLeave() {
|
|
982
|
+
try {
|
|
983
|
+
await this.#gateway.setVoiceChannel(this.guildId, null);
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
this.#connectionError ??= asError(error).message;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
#requirePlayback() {
|
|
990
|
+
this.#assertNotDisposed();
|
|
991
|
+
if (this.connectionState !== 'connected' || !this.#playback) {
|
|
992
|
+
throw new Error('Discord voice session is not connected');
|
|
993
|
+
}
|
|
994
|
+
return this.#playback;
|
|
995
|
+
}
|
|
996
|
+
#requireExistingPlayback() {
|
|
997
|
+
this.#assertNotDisposed();
|
|
998
|
+
if (!this.#playback)
|
|
999
|
+
throw new Error('Discord voice session has no playback session');
|
|
1000
|
+
return this.#playback;
|
|
1001
|
+
}
|
|
1002
|
+
async #replaceFilterGraph(graph) {
|
|
1003
|
+
this.#assertNotDisposed();
|
|
1004
|
+
await this.#serializeConfiguration(async () => {
|
|
1005
|
+
if (this.#connectionState === 'disconnecting') {
|
|
1006
|
+
throw new Error('Discord voice session is disconnecting');
|
|
1007
|
+
}
|
|
1008
|
+
const previousGraph = this.#filterGraph;
|
|
1009
|
+
if (previousGraph === graph)
|
|
1010
|
+
return;
|
|
1011
|
+
if (graph !== null)
|
|
1012
|
+
await this.#native.validateFilterGraph(graph);
|
|
1013
|
+
if (this.#playback) {
|
|
1014
|
+
if (graph === null)
|
|
1015
|
+
await this.#playback.filters.clear();
|
|
1016
|
+
else
|
|
1017
|
+
await this.#playback.filters.set(graph);
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
this.emit(PlaybackEvent.filterChanged, {
|
|
1021
|
+
sessionId: this.sessionId,
|
|
1022
|
+
guildId: this.guildId,
|
|
1023
|
+
nodeId: null,
|
|
1024
|
+
generation: this.#playbackGeneration,
|
|
1025
|
+
previousGraph,
|
|
1026
|
+
graph,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
this.#filterGraph = graph;
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
#serializeConfiguration(operation) {
|
|
1033
|
+
const result = this.#configurationWork.then(operation, operation);
|
|
1034
|
+
this.#configurationWork = result.catch(() => { });
|
|
1035
|
+
return result;
|
|
1036
|
+
}
|
|
1037
|
+
#assertNotDisposed() {
|
|
1038
|
+
if (this.#disposed || this.#closing)
|
|
1039
|
+
throw new Error('Discord voice session has been disposed');
|
|
1040
|
+
}
|
|
1041
|
+
#setConnectionState(state) {
|
|
1042
|
+
const previousState = normalizeLifecycleVoiceState(this.#connectionState);
|
|
1043
|
+
const nextState = normalizeLifecycleVoiceState(state);
|
|
1044
|
+
if (previousState === nextState)
|
|
1045
|
+
return;
|
|
1046
|
+
this.#connectionState = state;
|
|
1047
|
+
this.emit(VoiceEvent.voiceStateChanged, {
|
|
1048
|
+
sessionId: this.sessionId,
|
|
1049
|
+
guildId: this.guildId,
|
|
1050
|
+
nodeId: null,
|
|
1051
|
+
previousState,
|
|
1052
|
+
state: nextState,
|
|
1053
|
+
});
|
|
1054
|
+
if (nextState === 'failed') {
|
|
1055
|
+
this.emit(VoiceEvent.voiceFailed, {
|
|
1056
|
+
sessionId: this.sessionId,
|
|
1057
|
+
guildId: this.guildId,
|
|
1058
|
+
nodeId: null,
|
|
1059
|
+
state: 'failed',
|
|
1060
|
+
error: new Error(this.#connectionError ?? 'Discord voice transport failed'),
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
#forwardPlaybackEvents(playback) {
|
|
1065
|
+
this.#detachPlaybackEvents();
|
|
1066
|
+
const playbackEvents = playback;
|
|
1067
|
+
const unsubscribers = [];
|
|
1068
|
+
const forward = (name, listener) => {
|
|
1069
|
+
playbackEvents.on(name, listener);
|
|
1070
|
+
unsubscribers.push(() => playbackEvents.off(name, listener));
|
|
1071
|
+
};
|
|
1072
|
+
forward(PlaybackEvent.playbackStarted, (event) => {
|
|
1073
|
+
this.#playbackGeneration = event.generation;
|
|
1074
|
+
this.emit(PlaybackEvent.playbackStarted, {
|
|
1075
|
+
...event,
|
|
1076
|
+
sessionId: this.sessionId,
|
|
1077
|
+
guildId: this.guildId,
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
forward(PlaybackEvent.playbackCompleted, (event) => {
|
|
1081
|
+
this.emit(PlaybackEvent.playbackCompleted, {
|
|
1082
|
+
...event,
|
|
1083
|
+
sessionId: this.sessionId,
|
|
1084
|
+
guildId: this.guildId,
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
forward(PlaybackEvent.playbackStopped, (event) => {
|
|
1088
|
+
this.emit(PlaybackEvent.playbackStopped, {
|
|
1089
|
+
...event,
|
|
1090
|
+
sessionId: this.sessionId,
|
|
1091
|
+
guildId: this.guildId,
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
forward(PlaybackEvent.playbackFailed, (event) => {
|
|
1095
|
+
this.emit(PlaybackEvent.playbackFailed, {
|
|
1096
|
+
...event,
|
|
1097
|
+
sessionId: this.sessionId,
|
|
1098
|
+
guildId: this.guildId,
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
forward(PlaybackEvent.playbackPaused, (event) => {
|
|
1102
|
+
this.emit(PlaybackEvent.playbackPaused, {
|
|
1103
|
+
...event,
|
|
1104
|
+
sessionId: this.sessionId,
|
|
1105
|
+
guildId: this.guildId,
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
forward(PlaybackEvent.playbackResumed, (event) => {
|
|
1109
|
+
this.emit(PlaybackEvent.playbackResumed, {
|
|
1110
|
+
...event,
|
|
1111
|
+
sessionId: this.sessionId,
|
|
1112
|
+
guildId: this.guildId,
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
forward(PlaybackEvent.playbackSeeked, (event) => {
|
|
1116
|
+
this.emit(PlaybackEvent.playbackSeeked, {
|
|
1117
|
+
...event,
|
|
1118
|
+
sessionId: this.sessionId,
|
|
1119
|
+
guildId: this.guildId,
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
forward(PlaybackEvent.filterChanged, (event) => {
|
|
1123
|
+
this.emit(PlaybackEvent.filterChanged, {
|
|
1124
|
+
...event,
|
|
1125
|
+
sessionId: this.sessionId,
|
|
1126
|
+
guildId: this.guildId,
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
forward(PlaybackEvent.volumeChanged, (event) => {
|
|
1130
|
+
this.emit(PlaybackEvent.volumeChanged, {
|
|
1131
|
+
...event,
|
|
1132
|
+
sessionId: this.sessionId,
|
|
1133
|
+
guildId: this.guildId,
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
this.#playbackSubscriptions = {
|
|
1137
|
+
playback,
|
|
1138
|
+
detach: () => {
|
|
1139
|
+
for (const unsubscribe of unsubscribers.splice(0))
|
|
1140
|
+
unsubscribe();
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
#detachPlaybackEvents(playback) {
|
|
1145
|
+
const subscriptions = this.#playbackSubscriptions;
|
|
1146
|
+
if (!subscriptions || (playback && subscriptions.playback !== playback))
|
|
1147
|
+
return;
|
|
1148
|
+
this.#playbackSubscriptions = undefined;
|
|
1149
|
+
subscriptions.detach();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function validateSnowflake(name, value) {
|
|
1153
|
+
if (typeof value !== 'string' || !isSnowflake(value)) {
|
|
1154
|
+
throw new TypeError(`${name} must be a decimal Discord snowflake string`);
|
|
1155
|
+
}
|
|
1156
|
+
return value;
|
|
1157
|
+
}
|
|
1158
|
+
function asError(error) {
|
|
1159
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1160
|
+
}
|
|
1161
|
+
function abortError(message) {
|
|
1162
|
+
const error = new Error(message);
|
|
1163
|
+
error.name = 'AbortError';
|
|
1164
|
+
return error;
|
|
1165
|
+
}
|
|
1166
|
+
function normalizeLifecycleVoiceState(state) {
|
|
1167
|
+
return state === 'negotiatingDave' ? 'negotiating_dave' : state;
|
|
1168
|
+
}
|