@remedia/node 0.0.0-stage → 0.1.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/THIRD_PARTY_LICENSES.txt +15655 -0
- package/THIRD_PARTY_NOTICES.md +126 -0
- package/dist/discord.d.ts +69 -0
- package/dist/discord.js +997 -0
- package/dist/events.d.ts +11 -0
- package/dist/events.js +18 -0
- package/dist/filters.d.ts +11 -0
- package/dist/filters.js +36 -0
- package/dist/gateway.d.ts +45 -0
- package/dist/gateway.js +81 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +20 -0
- package/dist/input.js +124 -0
- package/dist/lifecycle.d.ts +166 -0
- package/dist/lifecycle.js +73 -0
- package/dist/playback.d.ts +29 -0
- package/dist/playback.js +387 -0
- package/dist/remedia.d.ts +32 -0
- package/dist/remedia.js +132 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +65 -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,997 @@
|
|
|
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
|
+
*/
|
|
13
|
+
export class DiscordSession extends RemediaEventEmitter {
|
|
14
|
+
sessionId = randomUUID();
|
|
15
|
+
guildId;
|
|
16
|
+
channelId;
|
|
17
|
+
userId;
|
|
18
|
+
#native;
|
|
19
|
+
#gateway;
|
|
20
|
+
#onDispose;
|
|
21
|
+
filters;
|
|
22
|
+
hooks = new PlaybackHooks();
|
|
23
|
+
#playback;
|
|
24
|
+
#playbackSubscriptions;
|
|
25
|
+
#nativeSession;
|
|
26
|
+
#attempt;
|
|
27
|
+
#voiceState;
|
|
28
|
+
#voiceServer;
|
|
29
|
+
#voiceServerUnavailable = false;
|
|
30
|
+
#nativeTransportGeneration = 0;
|
|
31
|
+
#gatewayEpoch;
|
|
32
|
+
#lastGatewaySequence;
|
|
33
|
+
#nativeWork = Promise.resolve();
|
|
34
|
+
#backgroundCleanup = Promise.resolve();
|
|
35
|
+
#disconnectPromise;
|
|
36
|
+
#connectionState = 'disconnected';
|
|
37
|
+
#connectionError;
|
|
38
|
+
#generation = 0;
|
|
39
|
+
#playbackGeneration = 0;
|
|
40
|
+
#joinRequested = false;
|
|
41
|
+
#disposed = false;
|
|
42
|
+
#filterGraph = null;
|
|
43
|
+
#volume = 1;
|
|
44
|
+
#configurationWork = Promise.resolve();
|
|
45
|
+
#nativeStateTimer;
|
|
46
|
+
/** @internal Constructed by `Remedia.createDiscordSession()`. */
|
|
47
|
+
constructor(native, gateway, options, onDispose) {
|
|
48
|
+
super();
|
|
49
|
+
this.guildId = validateSnowflake('guildId', options.guildId);
|
|
50
|
+
this.channelId = validateSnowflake('channelId', options.channelId);
|
|
51
|
+
this.userId = validateSnowflake('userId', options.userId);
|
|
52
|
+
this.#native = native;
|
|
53
|
+
this.#gateway = gateway;
|
|
54
|
+
this.#onDispose = onDispose;
|
|
55
|
+
this.filters = new FilterControls(() => this.#filterGraph, (graph) => this.#replaceFilterGraph(graph), () => this.#replaceFilterGraph(null));
|
|
56
|
+
}
|
|
57
|
+
/** Current voice transport state. A connected voice WebSocket may temporarily be reconnecting. */
|
|
58
|
+
get connectionState() {
|
|
59
|
+
if (this.#disposed)
|
|
60
|
+
return 'disconnected';
|
|
61
|
+
if (this.#connectionState === 'connected' && this.#nativeSession) {
|
|
62
|
+
try {
|
|
63
|
+
return this.#native.voiceState(this.#nativeSession.voiceId);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return this.#connectionState;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return this.#connectionState;
|
|
70
|
+
}
|
|
71
|
+
/** Most recent Gateway or native transport failure, if any. */
|
|
72
|
+
get connectionError() {
|
|
73
|
+
if (this.#connectionError)
|
|
74
|
+
return this.#connectionError;
|
|
75
|
+
if (!this.#nativeSession)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
return this.#native.voiceError(this.#nativeSession.voiceId) ?? null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
get state() {
|
|
85
|
+
if (this.#playback)
|
|
86
|
+
return this.#playback.state;
|
|
87
|
+
return this.connectionState === 'failed' ? 'failed' : 'idle';
|
|
88
|
+
}
|
|
89
|
+
get position() {
|
|
90
|
+
return (this.#playback?.position ?? {
|
|
91
|
+
mediaMs: null,
|
|
92
|
+
mediaPositionAccuracy: null,
|
|
93
|
+
submittedMs: 0,
|
|
94
|
+
playedMs: null,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
get durationMs() {
|
|
98
|
+
return this.#playback?.durationMs ?? null;
|
|
99
|
+
}
|
|
100
|
+
get error() {
|
|
101
|
+
return this.#playback?.error ?? this.connectionError;
|
|
102
|
+
}
|
|
103
|
+
/** Current normalized linear master gain, from 0.0 (mute) through 2.0. */
|
|
104
|
+
get volume() {
|
|
105
|
+
return this.#volume;
|
|
106
|
+
}
|
|
107
|
+
/** Current native source generation, useful to adapters that preserve event correlation. */
|
|
108
|
+
get generation() {
|
|
109
|
+
return this.#playback?.currentGeneration ?? this.#playbackGeneration;
|
|
110
|
+
}
|
|
111
|
+
/** Set master gain independently of this session's configured filtergraph. */
|
|
112
|
+
async setVolume(gain) {
|
|
113
|
+
this.#assertNotDisposed();
|
|
114
|
+
validateVolume(gain);
|
|
115
|
+
await this.#serializeConfiguration(async () => {
|
|
116
|
+
if (this.#connectionState === 'disconnecting') {
|
|
117
|
+
throw new Error('Discord voice session is disconnecting');
|
|
118
|
+
}
|
|
119
|
+
const previousVolume = this.#volume;
|
|
120
|
+
if (previousVolume === gain)
|
|
121
|
+
return;
|
|
122
|
+
if (this.#playback) {
|
|
123
|
+
await this.#playback.setVolume(gain);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
this.emit(PlaybackEvent.volumeChanged, {
|
|
127
|
+
sessionId: this.sessionId,
|
|
128
|
+
guildId: this.guildId,
|
|
129
|
+
nodeId: null,
|
|
130
|
+
generation: this.#playbackGeneration,
|
|
131
|
+
previousVolume,
|
|
132
|
+
volume: gain,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
this.#volume = gain;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Request a Gateway voice join and wait until both voice dispatches have arrived and the native
|
|
140
|
+
* voice transport has completed its DAVE setup. Repeated calls while connecting share a promise.
|
|
141
|
+
*/
|
|
142
|
+
connect(options = {}) {
|
|
143
|
+
this.#assertNotDisposed();
|
|
144
|
+
if (this.#attempt)
|
|
145
|
+
return this.#attempt.promise;
|
|
146
|
+
const currentState = this.connectionState;
|
|
147
|
+
if (currentState === 'connected' || currentState === 'reconnecting')
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
if (this.#connectionState === 'disconnecting') {
|
|
150
|
+
return Promise.reject(new Error('Discord voice session is disconnecting'));
|
|
151
|
+
}
|
|
152
|
+
if (options.signal?.aborted) {
|
|
153
|
+
return Promise.reject(abortError('Discord voice connection was cancelled'));
|
|
154
|
+
}
|
|
155
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
156
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
157
|
+
return Promise.reject(new RangeError('timeoutMs must be a positive finite number'));
|
|
158
|
+
}
|
|
159
|
+
const attempt = this.#createAttempt(timeoutMs, options.signal, false);
|
|
160
|
+
this.#attempt = attempt;
|
|
161
|
+
this.#voiceState = undefined;
|
|
162
|
+
this.#voiceServer = undefined;
|
|
163
|
+
this.#voiceServerUnavailable = false;
|
|
164
|
+
this.#connectionError = undefined;
|
|
165
|
+
this.#setConnectionState('connecting');
|
|
166
|
+
this.#joinRequested = true;
|
|
167
|
+
if (attempt.signal) {
|
|
168
|
+
attempt.abortListener = () => {
|
|
169
|
+
this.#failAttempt(attempt, abortError('Discord voice connection was cancelled'), true);
|
|
170
|
+
};
|
|
171
|
+
attempt.signal.addEventListener('abort', attempt.abortListener, { once: true });
|
|
172
|
+
}
|
|
173
|
+
this.#requestGatewayJoin(attempt);
|
|
174
|
+
return attempt.promise;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Begin playback on this connected voice session. The decoder and Opus encoder remain in core;
|
|
178
|
+
* this object only associates their output with the native Discord transport.
|
|
179
|
+
*/
|
|
180
|
+
play(source, options = {}) {
|
|
181
|
+
return this.#requirePlayback()
|
|
182
|
+
.play(source, options)
|
|
183
|
+
.then((info) => {
|
|
184
|
+
this.#playbackGeneration = this.#playback?.currentGeneration ?? this.#playbackGeneration;
|
|
185
|
+
return info;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
pause() {
|
|
189
|
+
return this.#requireExistingPlayback().pause();
|
|
190
|
+
}
|
|
191
|
+
resume() {
|
|
192
|
+
return this.#requireExistingPlayback().resume();
|
|
193
|
+
}
|
|
194
|
+
seek(positionMs) {
|
|
195
|
+
return this.#requireExistingPlayback().seek(positionMs);
|
|
196
|
+
}
|
|
197
|
+
stop() {
|
|
198
|
+
return this.#requireExistingPlayback().stop();
|
|
199
|
+
}
|
|
200
|
+
wait() {
|
|
201
|
+
return this.#requireExistingPlayback().wait();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Leave the Discord channel through Gateway OP 4 and close the native transport. This does not
|
|
205
|
+
* dispose the wrapper, so `connect()` can be called again with fresh Gateway dispatches.
|
|
206
|
+
*/
|
|
207
|
+
disconnect() {
|
|
208
|
+
if (this.#disposed)
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
if (this.#disconnectPromise)
|
|
211
|
+
return this.#disconnectPromise;
|
|
212
|
+
const disconnect = this.#disconnect();
|
|
213
|
+
this.#disconnectPromise = disconnect.finally(() => {
|
|
214
|
+
this.#disconnectPromise = undefined;
|
|
215
|
+
});
|
|
216
|
+
return this.#disconnectPromise;
|
|
217
|
+
}
|
|
218
|
+
/** Disconnect and unregister this session. */
|
|
219
|
+
async dispose() {
|
|
220
|
+
if (this.#disposed)
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
await this.disconnect();
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
this.#disposed = true;
|
|
227
|
+
this.emit(PlaybackEvent.sessionDisposed, {
|
|
228
|
+
sessionId: this.sessionId,
|
|
229
|
+
guildId: this.guildId,
|
|
230
|
+
nodeId: null,
|
|
231
|
+
generation: this.#playbackGeneration,
|
|
232
|
+
});
|
|
233
|
+
this.#onDispose();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** @internal Routes a parsed raw Gateway dispatch to this guild's active voice lifecycle. */
|
|
237
|
+
receiveGatewayDispatch(event, data, sequence, epoch) {
|
|
238
|
+
if (this.#disposed || data.guild_id !== this.guildId)
|
|
239
|
+
return;
|
|
240
|
+
if (epoch !== undefined && epoch !== this.#gatewayEpoch) {
|
|
241
|
+
this.#gatewayEpoch = epoch;
|
|
242
|
+
this.#lastGatewaySequence = undefined;
|
|
243
|
+
if (this.#attempt)
|
|
244
|
+
this.#attempt.sequenceFloor = undefined;
|
|
245
|
+
}
|
|
246
|
+
if (sequence !== undefined &&
|
|
247
|
+
this.#lastGatewaySequence !== undefined &&
|
|
248
|
+
sequence <= this.#lastGatewaySequence) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (sequence !== undefined)
|
|
252
|
+
this.#lastGatewaySequence = sequence;
|
|
253
|
+
const attempt = this.#attempt;
|
|
254
|
+
if (attempt &&
|
|
255
|
+
sequence !== undefined &&
|
|
256
|
+
attempt.sequenceFloor !== undefined &&
|
|
257
|
+
sequence <= attempt.sequenceFloor) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (event === 'VOICE_STATE_UPDATE') {
|
|
261
|
+
this.#receiveVoiceState(data);
|
|
262
|
+
}
|
|
263
|
+
else if (event === 'VOICE_SERVER_UPDATE') {
|
|
264
|
+
this.#receiveVoiceServer(data);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async #disconnect() {
|
|
268
|
+
const attempt = this.#attempt;
|
|
269
|
+
const shouldLeaveGateway = this.#joinRequested || this.#nativeSession !== undefined;
|
|
270
|
+
this.#joinRequested = false;
|
|
271
|
+
this.#generation += 1;
|
|
272
|
+
this.#stopNativeStateMonitor();
|
|
273
|
+
this.#voiceServerUnavailable = false;
|
|
274
|
+
this.#invalidateNativeTransport();
|
|
275
|
+
this.#setConnectionState('disconnecting');
|
|
276
|
+
this.#voiceState = undefined;
|
|
277
|
+
this.#voiceServer = undefined;
|
|
278
|
+
if (attempt) {
|
|
279
|
+
this.#finishAttempt(attempt);
|
|
280
|
+
attempt.reject(abortError('Discord voice connection was cancelled by disconnect()'));
|
|
281
|
+
}
|
|
282
|
+
const errors = [];
|
|
283
|
+
if (shouldLeaveGateway) {
|
|
284
|
+
try {
|
|
285
|
+
await this.#gateway.setVoiceChannel(this.guildId, null);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
errors.push(asError(error));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// A native connection attempt cannot be interrupted inside FFmpeg/transport setup. Its own
|
|
292
|
+
// network operations have finite timeouts; wait for its generation check to dispose late
|
|
293
|
+
// results before returning from disconnect.
|
|
294
|
+
await this.#nativeWork;
|
|
295
|
+
await this.#backgroundCleanup;
|
|
296
|
+
try {
|
|
297
|
+
await this.#closeCurrentNativeSession();
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
errors.push(asError(error));
|
|
301
|
+
}
|
|
302
|
+
this.#joinRequested = false;
|
|
303
|
+
this.#setConnectionState('disconnected');
|
|
304
|
+
if (errors.length > 0) {
|
|
305
|
+
this.#connectionError = errors.map((error) => error.message).join('; ');
|
|
306
|
+
throw new AggregateError(errors, 'Discord voice disconnection failed');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
#receiveVoiceState(data) {
|
|
310
|
+
if (data.user_id !== this.userId ||
|
|
311
|
+
!this.#joinRequested ||
|
|
312
|
+
this.#connectionState === 'disconnecting') {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (data.channel_id === null) {
|
|
316
|
+
if (this.#connectionState === 'connected' || this.#connectionState === 'reconnecting') {
|
|
317
|
+
this.#handleGatewayDisconnect();
|
|
318
|
+
}
|
|
319
|
+
// A null channel can be a delayed state from a previous leave while a new join is pending.
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (data.channel_id !== this.channelId || typeof data.session_id !== 'string')
|
|
323
|
+
return;
|
|
324
|
+
if (data.session_id.length === 0)
|
|
325
|
+
return;
|
|
326
|
+
const previousSessionId = this.#voiceState?.sessionId;
|
|
327
|
+
if (previousSessionId === data.session_id)
|
|
328
|
+
return;
|
|
329
|
+
const refreshRequired = !this.#attempt &&
|
|
330
|
+
(this.#connectionState === 'connected' ||
|
|
331
|
+
this.#connectionState === 'reconnecting' ||
|
|
332
|
+
this.#connectionState === 'disconnected' ||
|
|
333
|
+
this.#connectionState === 'failed');
|
|
334
|
+
this.#voiceState = { sessionId: data.session_id };
|
|
335
|
+
if (previousSessionId !== undefined) {
|
|
336
|
+
this.#voiceServer = undefined;
|
|
337
|
+
this.#voiceServerUnavailable = true;
|
|
338
|
+
}
|
|
339
|
+
if (refreshRequired) {
|
|
340
|
+
this.#beginCredentialRefresh();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.#credentialsChanged();
|
|
344
|
+
}
|
|
345
|
+
#receiveVoiceServer(data) {
|
|
346
|
+
if (!this.#joinRequested || this.#connectionState === 'disconnecting')
|
|
347
|
+
return;
|
|
348
|
+
if (data.endpoint === null) {
|
|
349
|
+
this.#voiceServerUnavailable = true;
|
|
350
|
+
if (!this.#attempt &&
|
|
351
|
+
['connected', 'reconnecting', 'disconnected', 'failed'].includes(this.#connectionState)) {
|
|
352
|
+
this.#beginCredentialRefresh();
|
|
353
|
+
}
|
|
354
|
+
else if (this.#attempt && !this.#attempt.internal) {
|
|
355
|
+
this.#failGateway(new Error('Discord did not provide a voice server endpoint'));
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (typeof data.token !== 'string' || typeof data.endpoint !== 'string')
|
|
360
|
+
return;
|
|
361
|
+
if (data.token.length === 0 || data.endpoint.length === 0)
|
|
362
|
+
return;
|
|
363
|
+
const sameServer = this.#voiceServer?.token === data.token && this.#voiceServer.endpoint === data.endpoint;
|
|
364
|
+
const currentState = this.connectionState;
|
|
365
|
+
const refreshRequired = !this.#attempt &&
|
|
366
|
+
currentState !== 'connecting' &&
|
|
367
|
+
currentState !== 'negotiatingDave' &&
|
|
368
|
+
(this.#voiceServerUnavailable || currentState !== 'connected' || !sameServer);
|
|
369
|
+
this.#voiceServerUnavailable = false;
|
|
370
|
+
if (sameServer && !refreshRequired) {
|
|
371
|
+
this.#credentialsChanged();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.#voiceServer = { token: data.token, endpoint: data.endpoint };
|
|
375
|
+
if (refreshRequired) {
|
|
376
|
+
this.#beginCredentialRefresh();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.#credentialsChanged();
|
|
380
|
+
}
|
|
381
|
+
#credentialsChanged() {
|
|
382
|
+
const attempt = this.#attempt;
|
|
383
|
+
if (!attempt ||
|
|
384
|
+
!this.#voiceState ||
|
|
385
|
+
!this.#voiceServer ||
|
|
386
|
+
this.#voiceServerUnavailable ||
|
|
387
|
+
!attempt.requestSent)
|
|
388
|
+
return;
|
|
389
|
+
this.#tryStartNativeConnection(attempt);
|
|
390
|
+
}
|
|
391
|
+
#createAttempt(timeoutMs, signal, internal) {
|
|
392
|
+
let resolve;
|
|
393
|
+
let reject;
|
|
394
|
+
const promise = new Promise((res, rej) => {
|
|
395
|
+
resolve = res;
|
|
396
|
+
reject = rej;
|
|
397
|
+
});
|
|
398
|
+
let attempt;
|
|
399
|
+
const onTimeout = () => {
|
|
400
|
+
const error = new Error(`Discord voice connection timed out after ${timeoutMs} ms`);
|
|
401
|
+
if (this.#retryCredentialRefresh(attempt, error)) {
|
|
402
|
+
attempt.timer = setTimeout(onTimeout, timeoutMs);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
this.#failAttempt(attempt, error, true);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
this.#generation += 1;
|
|
409
|
+
attempt = {
|
|
410
|
+
generation: this.#generation,
|
|
411
|
+
sequenceFloor: this.#gateway.lastSequence,
|
|
412
|
+
promise,
|
|
413
|
+
resolve,
|
|
414
|
+
reject,
|
|
415
|
+
timer: setTimeout(onTimeout, timeoutMs),
|
|
416
|
+
signal,
|
|
417
|
+
abortListener: undefined,
|
|
418
|
+
requestSent: false,
|
|
419
|
+
settled: false,
|
|
420
|
+
internal,
|
|
421
|
+
retryCount: 0,
|
|
422
|
+
transportGeneration: undefined,
|
|
423
|
+
};
|
|
424
|
+
return attempt;
|
|
425
|
+
}
|
|
426
|
+
#requestGatewayJoin(attempt) {
|
|
427
|
+
let sendPromise;
|
|
428
|
+
try {
|
|
429
|
+
sendPromise = this.#gateway.setVoiceChannel(this.guildId, this.channelId);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (!this.#retryCredentialRefresh(attempt, asError(error))) {
|
|
433
|
+
this.#failAttempt(attempt, asError(error), true);
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
void Promise.resolve(sendPromise)
|
|
438
|
+
.then(() => {
|
|
439
|
+
if (this.#attempt !== attempt)
|
|
440
|
+
return;
|
|
441
|
+
attempt.requestSent = true;
|
|
442
|
+
this.#tryStartNativeConnection(attempt);
|
|
443
|
+
})
|
|
444
|
+
.catch((error) => {
|
|
445
|
+
if (!this.#retryCredentialRefresh(attempt, asError(error))) {
|
|
446
|
+
this.#failAttempt(attempt, asError(error), true);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
#beginCredentialRefresh(requireFreshServer = false) {
|
|
451
|
+
if (this.#attempt || this.#disposed)
|
|
452
|
+
return;
|
|
453
|
+
if (requireFreshServer) {
|
|
454
|
+
this.#voiceServer = undefined;
|
|
455
|
+
this.#voiceServerUnavailable = true;
|
|
456
|
+
}
|
|
457
|
+
const attempt = this.#createAttempt(DEFAULT_CONNECT_TIMEOUT_MS, undefined, true);
|
|
458
|
+
// This internal attempt may have no direct caller waiting on its Promise.
|
|
459
|
+
void attempt.promise.catch(() => { });
|
|
460
|
+
this.#attempt = attempt;
|
|
461
|
+
this.#setConnectionState('reconnecting');
|
|
462
|
+
this.#connectionError = undefined;
|
|
463
|
+
this.#joinRequested = true;
|
|
464
|
+
this.#suspendCurrentNativeTransport(attempt);
|
|
465
|
+
this.#requestGatewayJoin(attempt);
|
|
466
|
+
}
|
|
467
|
+
#tryStartNativeConnection(attempt) {
|
|
468
|
+
if (this.#attempt !== attempt ||
|
|
469
|
+
!attempt.requestSent ||
|
|
470
|
+
!this.#voiceState ||
|
|
471
|
+
!this.#voiceServer ||
|
|
472
|
+
this.#voiceServerUnavailable) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
this.#scheduleNativeConnection(attempt);
|
|
476
|
+
}
|
|
477
|
+
#scheduleNativeConnection(attempt) {
|
|
478
|
+
if (!this.#voiceState || !this.#voiceServer)
|
|
479
|
+
return;
|
|
480
|
+
const credentials = {
|
|
481
|
+
...this.#voiceState,
|
|
482
|
+
...this.#voiceServer,
|
|
483
|
+
};
|
|
484
|
+
const generation = ++this.#generation;
|
|
485
|
+
if (attempt)
|
|
486
|
+
attempt.generation = generation;
|
|
487
|
+
const existing = this.#nativeSession;
|
|
488
|
+
const transportGeneration = existing ? this.#nextNativeTransportGeneration() : undefined;
|
|
489
|
+
if (existing && transportGeneration !== undefined) {
|
|
490
|
+
if (attempt)
|
|
491
|
+
attempt.transportGeneration = transportGeneration;
|
|
492
|
+
try {
|
|
493
|
+
this.#native.invalidateVoiceRecovery(existing.voiceId, transportGeneration);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
this.#nativeConnectionFailed(asError(error), generation, attempt);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.#setConnectionState(this.#nativeSession ? 'reconnecting' : 'negotiatingDave');
|
|
501
|
+
const work = this.#nativeWork.then(async () => {
|
|
502
|
+
if (!this.#isCurrent(generation, attempt))
|
|
503
|
+
return;
|
|
504
|
+
await this.#backgroundCleanup;
|
|
505
|
+
if (!this.#isCurrent(generation, attempt))
|
|
506
|
+
return;
|
|
507
|
+
const options = {
|
|
508
|
+
guildId: this.guildId,
|
|
509
|
+
channelId: this.channelId,
|
|
510
|
+
userId: this.userId,
|
|
511
|
+
sessionId: credentials.sessionId,
|
|
512
|
+
token: credentials.token,
|
|
513
|
+
endpoint: credentials.endpoint,
|
|
514
|
+
};
|
|
515
|
+
let playback;
|
|
516
|
+
if (existing) {
|
|
517
|
+
if (transportGeneration === undefined) {
|
|
518
|
+
throw new Error('Discord voice replacement generation is unavailable');
|
|
519
|
+
}
|
|
520
|
+
await this.#native.replaceVoice(existing.voiceId, transportGeneration, options);
|
|
521
|
+
if (!this.#isCurrent(generation, attempt))
|
|
522
|
+
return;
|
|
523
|
+
playback = this.#playback;
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
const nativeSession = await this.#native.connectVoice(options);
|
|
527
|
+
if (!this.#isCurrent(generation, attempt)) {
|
|
528
|
+
await this.#releaseNativeSession(nativeSession, undefined);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.#nativeSession = nativeSession;
|
|
532
|
+
const createdPlayback = new PlaybackSession(this.#native, nativeSession.playbackId, undefined, this.guildId, this.hooks);
|
|
533
|
+
playback = createdPlayback;
|
|
534
|
+
this.#playback = createdPlayback;
|
|
535
|
+
await this.#serializeConfiguration(async () => {
|
|
536
|
+
if (this.#playback !== createdPlayback)
|
|
537
|
+
return;
|
|
538
|
+
if (this.#filterGraph !== null)
|
|
539
|
+
await createdPlayback.filters.set(this.#filterGraph);
|
|
540
|
+
if (this.#volume !== 1)
|
|
541
|
+
await createdPlayback.setVolume(this.#volume);
|
|
542
|
+
});
|
|
543
|
+
if (!this.#isCurrent(generation, attempt))
|
|
544
|
+
return;
|
|
545
|
+
this.#forwardPlaybackEvents(createdPlayback);
|
|
546
|
+
}
|
|
547
|
+
this.#connectionError = undefined;
|
|
548
|
+
this.#setConnectionState('connected');
|
|
549
|
+
this.#voiceState = { sessionId: credentials.sessionId };
|
|
550
|
+
this.#voiceServer = { token: credentials.token, endpoint: credentials.endpoint };
|
|
551
|
+
this.#voiceServerUnavailable = false;
|
|
552
|
+
this.#startNativeStateMonitor();
|
|
553
|
+
this.#playbackGeneration = playback?.currentGeneration ?? this.#playbackGeneration;
|
|
554
|
+
if (attempt && this.#attempt === attempt) {
|
|
555
|
+
this.#finishAttempt(attempt);
|
|
556
|
+
attempt.resolve();
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
this.#nativeWork = work.catch((error) => {
|
|
560
|
+
this.#nativeConnectionFailed(asError(error), generation, attempt);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
#suspendCurrentNativeTransport(attempt) {
|
|
564
|
+
const nativeSession = this.#nativeSession;
|
|
565
|
+
if (!nativeSession)
|
|
566
|
+
return;
|
|
567
|
+
const generation = this.#nextNativeTransportGeneration();
|
|
568
|
+
attempt.transportGeneration = generation;
|
|
569
|
+
try {
|
|
570
|
+
this.#native.invalidateVoiceRecovery(nativeSession.voiceId, generation);
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
this.#nativeConnectionFailed(asError(error), attempt.generation, attempt);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const work = this.#nativeWork.then(() => this.#native.suspendVoice(nativeSession.voiceId, generation));
|
|
577
|
+
this.#nativeWork = work.catch((error) => {
|
|
578
|
+
if (this.#isCurrent(attempt.generation, attempt)) {
|
|
579
|
+
this.#connectionError = asError(error).message;
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
#startNativeStateMonitor() {
|
|
584
|
+
this.#stopNativeStateMonitor();
|
|
585
|
+
this.#nativeStateTimer = setInterval(() => {
|
|
586
|
+
if (this.#disposed || !this.#nativeSession || this.#attempt || !this.#joinRequested)
|
|
587
|
+
return;
|
|
588
|
+
try {
|
|
589
|
+
const state = this.#native.voiceState(this.#nativeSession.voiceId);
|
|
590
|
+
if (state === 'failed' || state === 'disconnected') {
|
|
591
|
+
this.#beginCredentialRefresh(true);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// Explicit close/disposal owns cleanup if the native entry disappeared.
|
|
596
|
+
}
|
|
597
|
+
}, 250);
|
|
598
|
+
this.#nativeStateTimer.unref?.();
|
|
599
|
+
}
|
|
600
|
+
#stopNativeStateMonitor() {
|
|
601
|
+
if (!this.#nativeStateTimer)
|
|
602
|
+
return;
|
|
603
|
+
clearInterval(this.#nativeStateTimer);
|
|
604
|
+
this.#nativeStateTimer = undefined;
|
|
605
|
+
}
|
|
606
|
+
#invalidateNativeTransport() {
|
|
607
|
+
const nativeSession = this.#nativeSession;
|
|
608
|
+
if (!nativeSession)
|
|
609
|
+
return;
|
|
610
|
+
try {
|
|
611
|
+
this.#native.invalidateVoiceRecovery(nativeSession.voiceId, this.#nextNativeTransportGeneration());
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// Explicit disconnect and disposal remain authoritative while native cleanup runs.
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
#nextNativeTransportGeneration() {
|
|
618
|
+
this.#nativeTransportGeneration =
|
|
619
|
+
this.#nativeTransportGeneration >= 0xffff_fffe ? 1 : this.#nativeTransportGeneration + 1;
|
|
620
|
+
return this.#nativeTransportGeneration;
|
|
621
|
+
}
|
|
622
|
+
#isCurrent(generation, attempt) {
|
|
623
|
+
return (!this.#disposed &&
|
|
624
|
+
this.#generation === generation &&
|
|
625
|
+
(attempt ? this.#attempt === attempt : this.#attempt === undefined));
|
|
626
|
+
}
|
|
627
|
+
#nativeConnectionFailed(error, generation, attempt) {
|
|
628
|
+
if (!this.#isCurrent(generation, attempt))
|
|
629
|
+
return;
|
|
630
|
+
if (attempt && this.#retryCredentialRefresh(attempt, error))
|
|
631
|
+
return;
|
|
632
|
+
this.#connectionError = error.message;
|
|
633
|
+
this.#stopNativeStateMonitor();
|
|
634
|
+
this.#setConnectionState('failed');
|
|
635
|
+
const pending = this.#attempt;
|
|
636
|
+
if (pending) {
|
|
637
|
+
this.#finishAttempt(pending);
|
|
638
|
+
pending.reject(error);
|
|
639
|
+
}
|
|
640
|
+
const shouldLeaveGateway = this.#joinRequested;
|
|
641
|
+
this.#joinRequested = false;
|
|
642
|
+
this.#generation += 1;
|
|
643
|
+
if (!attempt?.internal) {
|
|
644
|
+
this.#voiceState = undefined;
|
|
645
|
+
this.#voiceServer = undefined;
|
|
646
|
+
this.#voiceServerUnavailable = false;
|
|
647
|
+
}
|
|
648
|
+
if (attempt?.internal && this.#nativeSession) {
|
|
649
|
+
try {
|
|
650
|
+
this.#native.failVoiceRecovery(this.#nativeSession.voiceId, 'Discord voice recovery failed');
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// A concurrently disposed native instance already released the router.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
this.#queueCurrentNativeCleanup();
|
|
658
|
+
}
|
|
659
|
+
if (shouldLeaveGateway)
|
|
660
|
+
void this.#sendGatewayLeave();
|
|
661
|
+
}
|
|
662
|
+
#failGateway(error) {
|
|
663
|
+
const attempt = this.#attempt;
|
|
664
|
+
if (attempt) {
|
|
665
|
+
if (!this.#retryCredentialRefresh(attempt, error))
|
|
666
|
+
this.#failAttempt(attempt, error, true);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (this.#connectionState === 'connected' || this.#connectionState === 'reconnecting') {
|
|
670
|
+
this.#nativeConnectionFailed(error, this.#generation, undefined);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
#failAttempt(attempt, error, leaveGateway) {
|
|
674
|
+
if (this.#attempt !== attempt)
|
|
675
|
+
return;
|
|
676
|
+
this.#generation += 1;
|
|
677
|
+
this.#finishAttempt(attempt);
|
|
678
|
+
this.#connectionError = error.name === 'AbortError' ? undefined : error.message;
|
|
679
|
+
this.#stopNativeStateMonitor();
|
|
680
|
+
this.#setConnectionState(error.name === 'AbortError' ? 'disconnected' : 'failed');
|
|
681
|
+
if (!attempt.internal) {
|
|
682
|
+
this.#voiceState = undefined;
|
|
683
|
+
this.#voiceServer = undefined;
|
|
684
|
+
this.#voiceServerUnavailable = false;
|
|
685
|
+
}
|
|
686
|
+
if (attempt.internal && this.#nativeSession) {
|
|
687
|
+
try {
|
|
688
|
+
this.#native.failVoiceRecovery(this.#nativeSession.voiceId, 'Discord voice recovery timed out');
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
// The owning Remedia instance may already be closing.
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
this.#queueCurrentNativeCleanup();
|
|
696
|
+
}
|
|
697
|
+
const shouldLeaveGateway = leaveGateway && this.#joinRequested;
|
|
698
|
+
this.#joinRequested = false;
|
|
699
|
+
attempt.reject(error);
|
|
700
|
+
if (shouldLeaveGateway)
|
|
701
|
+
void this.#sendGatewayLeave();
|
|
702
|
+
}
|
|
703
|
+
#retryCredentialRefresh(attempt, _error) {
|
|
704
|
+
if (!attempt.internal ||
|
|
705
|
+
this.#attempt !== attempt ||
|
|
706
|
+
attempt.settled ||
|
|
707
|
+
attempt.retryCount >= 2 ||
|
|
708
|
+
this.#disposed) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
attempt.retryCount += 1;
|
|
712
|
+
this.#generation += 1;
|
|
713
|
+
attempt.generation = this.#generation;
|
|
714
|
+
attempt.sequenceFloor = this.#gateway.lastSequence;
|
|
715
|
+
attempt.requestSent = false;
|
|
716
|
+
this.#voiceServer = undefined;
|
|
717
|
+
this.#voiceServerUnavailable = true;
|
|
718
|
+
this.#setConnectionState('reconnecting');
|
|
719
|
+
this.#suspendCurrentNativeTransport(attempt);
|
|
720
|
+
this.#requestGatewayJoin(attempt);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
#finishAttempt(attempt) {
|
|
724
|
+
if (attempt.settled)
|
|
725
|
+
return;
|
|
726
|
+
attempt.settled = true;
|
|
727
|
+
clearTimeout(attempt.timer);
|
|
728
|
+
if (attempt.signal && attempt.abortListener) {
|
|
729
|
+
attempt.signal.removeEventListener('abort', attempt.abortListener);
|
|
730
|
+
}
|
|
731
|
+
if (this.#attempt === attempt)
|
|
732
|
+
this.#attempt = undefined;
|
|
733
|
+
}
|
|
734
|
+
async #handleGatewayDisconnect() {
|
|
735
|
+
const attempt = this.#attempt;
|
|
736
|
+
this.#generation += 1;
|
|
737
|
+
this.#setConnectionState('disconnecting');
|
|
738
|
+
this.#invalidateNativeTransport();
|
|
739
|
+
if (attempt) {
|
|
740
|
+
this.#finishAttempt(attempt);
|
|
741
|
+
attempt.reject(new Error('Discord Gateway reported that the bot left the voice channel'));
|
|
742
|
+
}
|
|
743
|
+
this.#joinRequested = false;
|
|
744
|
+
this.#voiceState = undefined;
|
|
745
|
+
this.#voiceServer = undefined;
|
|
746
|
+
this.#voiceServerUnavailable = false;
|
|
747
|
+
this.#stopNativeStateMonitor();
|
|
748
|
+
try {
|
|
749
|
+
await this.#nativeWork;
|
|
750
|
+
await this.#backgroundCleanup;
|
|
751
|
+
await this.#closeCurrentNativeSession();
|
|
752
|
+
this.#setConnectionState('disconnected');
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
this.#connectionError = asError(error).message;
|
|
756
|
+
this.#setConnectionState('failed');
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async #closeCurrentNativeSession() {
|
|
760
|
+
await this.#configurationWork;
|
|
761
|
+
const nativeSession = this.#nativeSession;
|
|
762
|
+
if (!nativeSession)
|
|
763
|
+
return;
|
|
764
|
+
this.#nativeSession = undefined;
|
|
765
|
+
const playback = this.#playback;
|
|
766
|
+
this.#playback = undefined;
|
|
767
|
+
await this.#releaseNativeSession(nativeSession, playback);
|
|
768
|
+
}
|
|
769
|
+
#queueCurrentNativeCleanup() {
|
|
770
|
+
const nativeSession = this.#nativeSession;
|
|
771
|
+
if (!nativeSession)
|
|
772
|
+
return;
|
|
773
|
+
this.#nativeSession = undefined;
|
|
774
|
+
const playback = this.#playback;
|
|
775
|
+
this.#playback = undefined;
|
|
776
|
+
this.#backgroundCleanup = this.#backgroundCleanup
|
|
777
|
+
.then(() => this.#releaseNativeSession(nativeSession, playback))
|
|
778
|
+
.catch((error) => {
|
|
779
|
+
this.#connectionError ??= asError(error).message;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
async #releaseNativeSession(nativeSession, playback) {
|
|
783
|
+
const errors = [];
|
|
784
|
+
try {
|
|
785
|
+
await playback?.releaseInput();
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
errors.push(asError(error));
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
await this.#native.disconnectVoice(nativeSession.voiceId);
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
errors.push(asError(error));
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
this.#native.releaseVoiceSession(nativeSession.voiceId);
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
errors.push(asError(error));
|
|
801
|
+
}
|
|
802
|
+
finally {
|
|
803
|
+
if (playback)
|
|
804
|
+
this.#detachPlaybackEvents(playback);
|
|
805
|
+
}
|
|
806
|
+
if (errors.length > 0) {
|
|
807
|
+
throw new AggregateError(errors, 'could not release native Discord voice session');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
async #sendGatewayLeave() {
|
|
811
|
+
try {
|
|
812
|
+
await this.#gateway.setVoiceChannel(this.guildId, null);
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
this.#connectionError ??= asError(error).message;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
#requirePlayback() {
|
|
819
|
+
this.#assertNotDisposed();
|
|
820
|
+
if (this.connectionState !== 'connected' || !this.#playback) {
|
|
821
|
+
throw new Error('Discord voice session is not connected');
|
|
822
|
+
}
|
|
823
|
+
return this.#playback;
|
|
824
|
+
}
|
|
825
|
+
#requireExistingPlayback() {
|
|
826
|
+
this.#assertNotDisposed();
|
|
827
|
+
if (!this.#playback)
|
|
828
|
+
throw new Error('Discord voice session has no playback session');
|
|
829
|
+
return this.#playback;
|
|
830
|
+
}
|
|
831
|
+
async #replaceFilterGraph(graph) {
|
|
832
|
+
this.#assertNotDisposed();
|
|
833
|
+
await this.#serializeConfiguration(async () => {
|
|
834
|
+
if (this.#connectionState === 'disconnecting') {
|
|
835
|
+
throw new Error('Discord voice session is disconnecting');
|
|
836
|
+
}
|
|
837
|
+
const previousGraph = this.#filterGraph;
|
|
838
|
+
if (previousGraph === graph)
|
|
839
|
+
return;
|
|
840
|
+
if (graph !== null)
|
|
841
|
+
await this.#native.validateFilterGraph(graph);
|
|
842
|
+
if (this.#playback) {
|
|
843
|
+
if (graph === null)
|
|
844
|
+
await this.#playback.filters.clear();
|
|
845
|
+
else
|
|
846
|
+
await this.#playback.filters.set(graph);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
this.emit(PlaybackEvent.filterChanged, {
|
|
850
|
+
sessionId: this.sessionId,
|
|
851
|
+
guildId: this.guildId,
|
|
852
|
+
nodeId: null,
|
|
853
|
+
generation: this.#playbackGeneration,
|
|
854
|
+
previousGraph,
|
|
855
|
+
graph,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
this.#filterGraph = graph;
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
#serializeConfiguration(operation) {
|
|
862
|
+
const result = this.#configurationWork.then(operation, operation);
|
|
863
|
+
this.#configurationWork = result.catch(() => { });
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
#assertNotDisposed() {
|
|
867
|
+
if (this.#disposed)
|
|
868
|
+
throw new Error('Discord voice session has been disposed');
|
|
869
|
+
}
|
|
870
|
+
#setConnectionState(state) {
|
|
871
|
+
const previousState = normalizeLifecycleVoiceState(this.#connectionState);
|
|
872
|
+
const nextState = normalizeLifecycleVoiceState(state);
|
|
873
|
+
if (previousState === nextState)
|
|
874
|
+
return;
|
|
875
|
+
this.#connectionState = state;
|
|
876
|
+
this.emit(VoiceEvent.voiceStateChanged, {
|
|
877
|
+
sessionId: this.sessionId,
|
|
878
|
+
guildId: this.guildId,
|
|
879
|
+
nodeId: null,
|
|
880
|
+
previousState,
|
|
881
|
+
state: nextState,
|
|
882
|
+
});
|
|
883
|
+
if (nextState === 'failed') {
|
|
884
|
+
this.emit(VoiceEvent.voiceFailed, {
|
|
885
|
+
sessionId: this.sessionId,
|
|
886
|
+
guildId: this.guildId,
|
|
887
|
+
nodeId: null,
|
|
888
|
+
state: 'failed',
|
|
889
|
+
error: new Error(this.#connectionError ?? 'Discord voice transport failed'),
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
#forwardPlaybackEvents(playback) {
|
|
894
|
+
this.#detachPlaybackEvents();
|
|
895
|
+
const playbackEvents = playback;
|
|
896
|
+
const unsubscribers = [];
|
|
897
|
+
const forward = (name, listener) => {
|
|
898
|
+
playbackEvents.on(name, listener);
|
|
899
|
+
unsubscribers.push(() => playbackEvents.off(name, listener));
|
|
900
|
+
};
|
|
901
|
+
forward(PlaybackEvent.playbackStarted, (event) => {
|
|
902
|
+
this.#playbackGeneration = event.generation;
|
|
903
|
+
this.emit(PlaybackEvent.playbackStarted, {
|
|
904
|
+
...event,
|
|
905
|
+
sessionId: this.sessionId,
|
|
906
|
+
guildId: this.guildId,
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
forward(PlaybackEvent.playbackCompleted, (event) => {
|
|
910
|
+
this.emit(PlaybackEvent.playbackCompleted, {
|
|
911
|
+
...event,
|
|
912
|
+
sessionId: this.sessionId,
|
|
913
|
+
guildId: this.guildId,
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
forward(PlaybackEvent.playbackStopped, (event) => {
|
|
917
|
+
this.emit(PlaybackEvent.playbackStopped, {
|
|
918
|
+
...event,
|
|
919
|
+
sessionId: this.sessionId,
|
|
920
|
+
guildId: this.guildId,
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
forward(PlaybackEvent.playbackFailed, (event) => {
|
|
924
|
+
this.emit(PlaybackEvent.playbackFailed, {
|
|
925
|
+
...event,
|
|
926
|
+
sessionId: this.sessionId,
|
|
927
|
+
guildId: this.guildId,
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
forward(PlaybackEvent.playbackPaused, (event) => {
|
|
931
|
+
this.emit(PlaybackEvent.playbackPaused, {
|
|
932
|
+
...event,
|
|
933
|
+
sessionId: this.sessionId,
|
|
934
|
+
guildId: this.guildId,
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
forward(PlaybackEvent.playbackResumed, (event) => {
|
|
938
|
+
this.emit(PlaybackEvent.playbackResumed, {
|
|
939
|
+
...event,
|
|
940
|
+
sessionId: this.sessionId,
|
|
941
|
+
guildId: this.guildId,
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
forward(PlaybackEvent.playbackSeeked, (event) => {
|
|
945
|
+
this.emit(PlaybackEvent.playbackSeeked, {
|
|
946
|
+
...event,
|
|
947
|
+
sessionId: this.sessionId,
|
|
948
|
+
guildId: this.guildId,
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
forward(PlaybackEvent.filterChanged, (event) => {
|
|
952
|
+
this.emit(PlaybackEvent.filterChanged, {
|
|
953
|
+
...event,
|
|
954
|
+
sessionId: this.sessionId,
|
|
955
|
+
guildId: this.guildId,
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
forward(PlaybackEvent.volumeChanged, (event) => {
|
|
959
|
+
this.emit(PlaybackEvent.volumeChanged, {
|
|
960
|
+
...event,
|
|
961
|
+
sessionId: this.sessionId,
|
|
962
|
+
guildId: this.guildId,
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
this.#playbackSubscriptions = {
|
|
966
|
+
playback,
|
|
967
|
+
detach: () => {
|
|
968
|
+
for (const unsubscribe of unsubscribers.splice(0))
|
|
969
|
+
unsubscribe();
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
#detachPlaybackEvents(playback) {
|
|
974
|
+
const subscriptions = this.#playbackSubscriptions;
|
|
975
|
+
if (!subscriptions || (playback && subscriptions.playback !== playback))
|
|
976
|
+
return;
|
|
977
|
+
this.#playbackSubscriptions = undefined;
|
|
978
|
+
subscriptions.detach();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function validateSnowflake(name, value) {
|
|
982
|
+
if (typeof value !== 'string' || !isSnowflake(value)) {
|
|
983
|
+
throw new TypeError(`${name} must be a decimal Discord snowflake string`);
|
|
984
|
+
}
|
|
985
|
+
return value;
|
|
986
|
+
}
|
|
987
|
+
function asError(error) {
|
|
988
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
989
|
+
}
|
|
990
|
+
function abortError(message) {
|
|
991
|
+
const error = new Error(message);
|
|
992
|
+
error.name = 'AbortError';
|
|
993
|
+
return error;
|
|
994
|
+
}
|
|
995
|
+
function normalizeLifecycleVoiceState(state) {
|
|
996
|
+
return state === 'negotiatingDave' ? 'negotiating_dave' : state;
|
|
997
|
+
}
|