ryanlink 1.0.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 +37 -0
- package/README.md +455 -0
- package/dist/index.d.mts +1335 -0
- package/dist/index.d.ts +1335 -0
- package/dist/index.js +4694 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4604 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/src/audio/AudioFilters.ts +316 -0
- package/src/audio/AudioQueue.ts +782 -0
- package/src/audio/AudioTrack.ts +242 -0
- package/src/audio/QueueController.ts +252 -0
- package/src/audio/TrackCollection.ts +138 -0
- package/src/audio/index.ts +9 -0
- package/src/config/defaults.ts +223 -0
- package/src/config/endpoints.ts +99 -0
- package/src/config/index.ts +9 -0
- package/src/config/patterns.ts +55 -0
- package/src/config/presets.ts +400 -0
- package/src/config/symbols.ts +31 -0
- package/src/core/PluginSystem.ts +50 -0
- package/src/core/RyanlinkPlayer.ts +403 -0
- package/src/core/index.ts +6 -0
- package/src/extensions/AutoplayExtension.ts +283 -0
- package/src/extensions/FairPlayExtension.ts +154 -0
- package/src/extensions/LyricsExtension.ts +187 -0
- package/src/extensions/PersistenceExtension.ts +182 -0
- package/src/extensions/SponsorBlockExtension.ts +81 -0
- package/src/extensions/index.ts +9 -0
- package/src/index.ts +19 -0
- package/src/lavalink/ConnectionPool.ts +326 -0
- package/src/lavalink/HttpClient.ts +316 -0
- package/src/lavalink/LavalinkConnection.ts +409 -0
- package/src/lavalink/index.ts +7 -0
- package/src/metadata.ts +88 -0
- package/src/types/api/Rest.ts +949 -0
- package/src/types/api/Websocket.ts +463 -0
- package/src/types/api/index.ts +6 -0
- package/src/types/audio/FilterManager.ts +29 -0
- package/src/types/audio/Queue.ts +4 -0
- package/src/types/audio/QueueManager.ts +30 -0
- package/src/types/audio/index.ts +7 -0
- package/src/types/common.ts +63 -0
- package/src/types/core/Player.ts +322 -0
- package/src/types/core/index.ts +5 -0
- package/src/types/index.ts +6 -0
- package/src/types/lavalink/Node.ts +173 -0
- package/src/types/lavalink/NodeManager.ts +34 -0
- package/src/types/lavalink/REST.ts +144 -0
- package/src/types/lavalink/index.ts +32 -0
- package/src/types/voice/VoiceManager.ts +176 -0
- package/src/types/voice/index.ts +5 -0
- package/src/utils/helpers.ts +169 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/validators.ts +184 -0
- package/src/voice/RegionSelector.ts +184 -0
- package/src/voice/VoiceConnection.ts +451 -0
- package/src/voice/VoiceSession.ts +297 -0
- package/src/voice/index.ts +7 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { OnPingUpdateSymbol } from "../config/symbols";
|
|
2
|
+
import type { PlayerState } from "../types";
|
|
3
|
+
import type { Player } from "../core/RyanlinkPlayer";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Voice node ping statistics
|
|
7
|
+
*/
|
|
8
|
+
interface VoiceNodePingStats {
|
|
9
|
+
/**
|
|
10
|
+
* History of ping measurements (up to 5 samples)
|
|
11
|
+
*/
|
|
12
|
+
history: number[];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Timestamp of last ping measurement
|
|
16
|
+
*/
|
|
17
|
+
lastPingTime: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Represents a voice region with node ping tracking
|
|
22
|
+
* Used for latency-based node selection
|
|
23
|
+
*/
|
|
24
|
+
export class VoiceRegion {
|
|
25
|
+
#pings = new Map<string, VoiceNodePingStats>();
|
|
26
|
+
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly player: Player;
|
|
29
|
+
|
|
30
|
+
constructor(player: Player, regionId: string) {
|
|
31
|
+
if (player.voices.regions.has(regionId)) {
|
|
32
|
+
throw new Error("An identical voice region already exists");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.id = regionId;
|
|
36
|
+
this.player = player;
|
|
37
|
+
|
|
38
|
+
// Make properties immutable
|
|
39
|
+
const immutable: PropertyDescriptor = {
|
|
40
|
+
writable: false,
|
|
41
|
+
configurable: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
Object.defineProperties(this, {
|
|
45
|
+
id: immutable,
|
|
46
|
+
player: { ...immutable, enumerable: false },
|
|
47
|
+
} as PropertyDescriptorMap);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if all ready nodes have ping data for this region
|
|
52
|
+
* @returns `true` if all nodes are synced, `false` otherwise
|
|
53
|
+
*/
|
|
54
|
+
inSync(): boolean {
|
|
55
|
+
return !Array.from(this.player.nodes.values()).some((n) => n.ready && !this.#pings.has(n.name));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remove ping data for a node
|
|
60
|
+
* @param name Node name
|
|
61
|
+
* @returns `true` if data was removed, `false` if it didn't exist
|
|
62
|
+
*/
|
|
63
|
+
forgetNode(name: string): boolean {
|
|
64
|
+
return this.#pings.delete(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the average ping for a node in this region
|
|
69
|
+
* @param name Node name
|
|
70
|
+
* @returns Average ping in milliseconds, or `null` if no data
|
|
71
|
+
*/
|
|
72
|
+
getAveragePing(name: string): number | null {
|
|
73
|
+
const pings = this.#pings.get(name)?.history;
|
|
74
|
+
if (!pings?.length) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Math.round(pings.reduce((total, current) => total + current, 0) / pings.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get all nodes with their average pings
|
|
83
|
+
* @returns Array of [nodeName, averagePing] tuples
|
|
84
|
+
*/
|
|
85
|
+
getAllPings(): Array<[string, number | null]> {
|
|
86
|
+
return Array.from(this.player.nodes.values()).map((node) => [node.name, this.getAveragePing(node.name)]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the most relevant node for this region
|
|
91
|
+
* Selects based on lowest average ping
|
|
92
|
+
* @returns The node with lowest ping, or first relevant node if no ping data
|
|
93
|
+
*/
|
|
94
|
+
getRelevantNode() {
|
|
95
|
+
return this.player.nodes.relevant().sort((a, b) => {
|
|
96
|
+
const pingA = this.getAveragePing(a.name) ?? Number.MAX_SAFE_INTEGER;
|
|
97
|
+
const pingB = this.getAveragePing(b.name) ?? Number.MAX_SAFE_INTEGER;
|
|
98
|
+
return pingA - pingB;
|
|
99
|
+
})[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Internal method to update ping statistics
|
|
104
|
+
* Called by the voice manager when player state updates
|
|
105
|
+
* @param name Node name
|
|
106
|
+
* @param state Player state with ping information
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
[OnPingUpdateSymbol](name: string, state: PlayerState): void {
|
|
110
|
+
// Only process if connected and has valid ping data
|
|
111
|
+
if (!state.connected) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (state.ping <= 0 || state.time <= 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pings = this.#pings.get(name);
|
|
119
|
+
|
|
120
|
+
// Initialize ping tracking for this node
|
|
121
|
+
if (!pings) {
|
|
122
|
+
this.#pings.set(name, {
|
|
123
|
+
history: [state.ping],
|
|
124
|
+
lastPingTime: state.time,
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Only update if enough time has passed (12 seconds)
|
|
130
|
+
if (state.time - pings.lastPingTime < 12_000) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pings.lastPingTime = state.time;
|
|
135
|
+
pings.history.push(state.ping);
|
|
136
|
+
|
|
137
|
+
// Keep only last 5 samples
|
|
138
|
+
if (pings.history.length > 5) {
|
|
139
|
+
pings.history.shift();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Clear all ping data for this region
|
|
145
|
+
*/
|
|
146
|
+
clear(): void {
|
|
147
|
+
this.#pings.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get the number of nodes with ping data
|
|
152
|
+
*/
|
|
153
|
+
get nodeCount(): number {
|
|
154
|
+
return this.#pings.size;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* String representation of the voice region
|
|
159
|
+
*/
|
|
160
|
+
toString(): string {
|
|
161
|
+
return `VoiceRegion<${this.id}>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* JSON representation of the voice region
|
|
166
|
+
*/
|
|
167
|
+
toJSON(): {
|
|
168
|
+
id: string;
|
|
169
|
+
inSync: boolean;
|
|
170
|
+
nodeCount: number;
|
|
171
|
+
pings: Array<{ node: string; averagePing: number | null; samples: number }>;
|
|
172
|
+
} {
|
|
173
|
+
return {
|
|
174
|
+
id: this.id,
|
|
175
|
+
inSync: this.inSync(),
|
|
176
|
+
nodeCount: this.nodeCount,
|
|
177
|
+
pings: Array.from(this.#pings.entries()).map(([node, stats]) => ({
|
|
178
|
+
node,
|
|
179
|
+
averagePing: this.getAveragePing(node),
|
|
180
|
+
samples: stats.history.length,
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
2
|
+
import { SnowflakeRegex, VoiceRegionIdRegex } from "../config";
|
|
3
|
+
import { LookupSymbol, OnVoiceCloseSymbol, UpdateSymbol } from "../config/symbols";
|
|
4
|
+
import { isString, noop } from "../utils";
|
|
5
|
+
import { VoiceRegion } from "./RegionSelector";
|
|
6
|
+
import { VoiceState } from "./VoiceSession";
|
|
7
|
+
import {
|
|
8
|
+
VoiceCloseCodes,
|
|
9
|
+
type BotReadyPayload,
|
|
10
|
+
type BotVoiceState,
|
|
11
|
+
type ConnectOptions,
|
|
12
|
+
type CreateQueueOptions,
|
|
13
|
+
type DiscordDispatchPayload,
|
|
14
|
+
type VoiceServerUpdatePayload,
|
|
15
|
+
type VoiceStateUpdatePayload,
|
|
16
|
+
} from "../types";
|
|
17
|
+
import type { Player } from "../core/RyanlinkPlayer";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Join request with promise resolvers
|
|
21
|
+
*/
|
|
22
|
+
interface JoinRequest extends Pick<CreateQueueOptions, "context" | "node" | "voiceId"> {
|
|
23
|
+
promise: Promise<VoiceState>;
|
|
24
|
+
resolve: (value: VoiceState | PromiseLike<VoiceState>) => void;
|
|
25
|
+
reject: (reason?: unknown) => void;
|
|
26
|
+
config?: Pick<CreateQueueOptions, "filters" | "volume">;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Manages voice connections for all guilds
|
|
31
|
+
* Handles Discord voice events and Lavalink voice state
|
|
32
|
+
*/
|
|
33
|
+
export class VoiceManager implements Partial<Map<string, VoiceState>> {
|
|
34
|
+
#cache = new Map<string, BotVoiceState>();
|
|
35
|
+
#voices = new Map<string, VoiceState>();
|
|
36
|
+
|
|
37
|
+
#joins = new Map<string, JoinRequest>();
|
|
38
|
+
#destroys = new Map<string, Promise<void>>();
|
|
39
|
+
|
|
40
|
+
readonly regions = new Map<string, VoiceRegion>();
|
|
41
|
+
readonly player: Player;
|
|
42
|
+
|
|
43
|
+
constructor(player: Player) {
|
|
44
|
+
if (player.voices === undefined) {
|
|
45
|
+
this.player = player;
|
|
46
|
+
} else {
|
|
47
|
+
throw new Error("Manager already exists for this Player");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const immutable: PropertyDescriptor = {
|
|
51
|
+
writable: false,
|
|
52
|
+
configurable: false,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
Object.defineProperties(this, {
|
|
56
|
+
regions: immutable,
|
|
57
|
+
player: { ...immutable, enumerable: false },
|
|
58
|
+
} as PropertyDescriptorMap);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Number of voice connections
|
|
63
|
+
*/
|
|
64
|
+
get size(): number {
|
|
65
|
+
return this.#voices.size;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get voice state for a guild
|
|
70
|
+
*/
|
|
71
|
+
get(guildId: string): VoiceState | undefined {
|
|
72
|
+
return this.#voices.get(guildId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if voice state exists
|
|
77
|
+
*/
|
|
78
|
+
has(guildId: string): boolean {
|
|
79
|
+
return this.#voices.has(guildId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get all connected guild IDs
|
|
84
|
+
*/
|
|
85
|
+
keys() {
|
|
86
|
+
return this.#voices.keys();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get all voice connections
|
|
91
|
+
*/
|
|
92
|
+
values() {
|
|
93
|
+
return this.#voices.values();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get all voice connections as entries
|
|
98
|
+
*/
|
|
99
|
+
entries() {
|
|
100
|
+
return this.#voices.entries();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Destroy a voice connection
|
|
105
|
+
* @param guildId Guild ID
|
|
106
|
+
* @param reason Reason for destruction
|
|
107
|
+
*/
|
|
108
|
+
async destroy(guildId: string, reason = "destroyed"): Promise<void> {
|
|
109
|
+
if (this.player.queues.has(guildId)) {
|
|
110
|
+
return this.player.queues.destroy(guildId, reason);
|
|
111
|
+
}
|
|
112
|
+
if (this.#destroys.has(guildId)) {
|
|
113
|
+
return this.#destroys.get(guildId) ?? Promise.resolve();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const voice = this.#voices.get(guildId);
|
|
117
|
+
if (!voice) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let resolve!: () => void;
|
|
122
|
+
let reject!: (reason?: unknown) => void;
|
|
123
|
+
const promise = new Promise<void>((res, rej) => {
|
|
124
|
+
resolve = res;
|
|
125
|
+
reject = rej;
|
|
126
|
+
});
|
|
127
|
+
const resolver = { promise, resolve, reject };
|
|
128
|
+
this.#destroys.set(guildId, resolver.promise);
|
|
129
|
+
|
|
130
|
+
if (this[LookupSymbol](guildId)?.connected) {
|
|
131
|
+
await voice.disconnect().catch(noop);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.#cache.delete(guildId);
|
|
135
|
+
this.#voices.delete(guildId);
|
|
136
|
+
|
|
137
|
+
this.player.emit("voiceDestroy", voice, reason);
|
|
138
|
+
|
|
139
|
+
resolver.resolve();
|
|
140
|
+
this.#destroys.delete(guildId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Connect to a voice channel
|
|
145
|
+
* @param guildId Guild ID
|
|
146
|
+
* @param voiceId Voice channel ID
|
|
147
|
+
* @param options Connection options
|
|
148
|
+
*/
|
|
149
|
+
async connect(guildId: string, voiceId: string, options?: ConnectOptions): Promise<VoiceState> {
|
|
150
|
+
if (!isString(guildId, SnowflakeRegex)) {
|
|
151
|
+
throw new Error("Guild Id is not a valid Discord Id");
|
|
152
|
+
}
|
|
153
|
+
if (!isString(voiceId, SnowflakeRegex)) {
|
|
154
|
+
throw new Error("Voice Id is not a valid Discord Id");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const currentRequest = this.#joins.get(guildId);
|
|
158
|
+
if (currentRequest) {
|
|
159
|
+
if (currentRequest.voiceId === voiceId) {
|
|
160
|
+
return currentRequest.promise;
|
|
161
|
+
}
|
|
162
|
+
currentRequest.reject(new Error("Connection request was replaced"));
|
|
163
|
+
}
|
|
164
|
+
this.#joins.delete(guildId);
|
|
165
|
+
|
|
166
|
+
let resolve!: (value: VoiceState | PromiseLike<VoiceState>) => void;
|
|
167
|
+
let reject!: (reason?: unknown) => void;
|
|
168
|
+
const promise = new Promise<VoiceState>((res, rej) => {
|
|
169
|
+
resolve = res;
|
|
170
|
+
reject = rej;
|
|
171
|
+
});
|
|
172
|
+
const request: JoinRequest = {
|
|
173
|
+
promise,
|
|
174
|
+
resolve,
|
|
175
|
+
reject,
|
|
176
|
+
voiceId,
|
|
177
|
+
node: options?.node,
|
|
178
|
+
context: options?.context,
|
|
179
|
+
config: options ? { filters: options.filters, volume: options.volume } : undefined,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
this.#joins.set(guildId, request);
|
|
183
|
+
|
|
184
|
+
// Send voice state update to Discord
|
|
185
|
+
await this.player.options.forwardVoiceUpdate(guildId, {
|
|
186
|
+
op: 4,
|
|
187
|
+
d: {
|
|
188
|
+
guild_id: guildId,
|
|
189
|
+
channel_id: voiceId,
|
|
190
|
+
self_deaf: false,
|
|
191
|
+
self_mute: false,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Wait for voice connection with timeout
|
|
196
|
+
const timeout = setTimeout(15_000, undefined, { ref: false });
|
|
197
|
+
const result = await Promise.race([request.promise, timeout]);
|
|
198
|
+
|
|
199
|
+
if (result === undefined) {
|
|
200
|
+
this.#joins.delete(guildId);
|
|
201
|
+
throw new Error("Voice connection timed out");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Disconnect from voice channel
|
|
209
|
+
* @param guildId Guild ID
|
|
210
|
+
*/
|
|
211
|
+
async disconnect(guildId: string): Promise<void> {
|
|
212
|
+
const state = this.#cache.get(guildId);
|
|
213
|
+
if (!state) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.player.options.forwardVoiceUpdate(guildId, {
|
|
218
|
+
op: 4,
|
|
219
|
+
d: {
|
|
220
|
+
guild_id: guildId,
|
|
221
|
+
channel_id: null,
|
|
222
|
+
self_deaf: false,
|
|
223
|
+
self_mute: false,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
state.channel_id = "";
|
|
228
|
+
state.connected = false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Handle Discord dispatch events
|
|
233
|
+
* @param payload Discord gateway payload
|
|
234
|
+
*/
|
|
235
|
+
handleDispatch(payload: DiscordDispatchPayload): void {
|
|
236
|
+
if (payload.t === "READY") {
|
|
237
|
+
this.#handleReady(payload);
|
|
238
|
+
} else if (payload.t === "VOICE_STATE_UPDATE") {
|
|
239
|
+
this.#handleVoiceStateUpdate(payload);
|
|
240
|
+
} else if (payload.t === "VOICE_SERVER_UPDATE") {
|
|
241
|
+
this.#handleVoiceServerUpdate(payload);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Internal lookup for voice state
|
|
247
|
+
* @internal
|
|
248
|
+
*/
|
|
249
|
+
[LookupSymbol](guildId: string): BotVoiceState | undefined {
|
|
250
|
+
return this.#cache.get(guildId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Internal update for voice state
|
|
255
|
+
* @internal
|
|
256
|
+
*/
|
|
257
|
+
[UpdateSymbol](guildId: string, partial: Partial<BotVoiceState>): void {
|
|
258
|
+
const state = this.#cache.get(guildId);
|
|
259
|
+
if (state) {
|
|
260
|
+
Object.assign(state, partial);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Internal voice close handler
|
|
266
|
+
* @internal
|
|
267
|
+
*/
|
|
268
|
+
[OnVoiceCloseSymbol](guildId: string, code: number, reason: string, byRemote: boolean): void {
|
|
269
|
+
const voice = this.#voices.get(guildId);
|
|
270
|
+
if (!voice) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.player.emit("voiceClose", voice, code, reason, byRemote);
|
|
275
|
+
|
|
276
|
+
// Check if we should destroy the connection
|
|
277
|
+
const shouldDestroy =
|
|
278
|
+
(code as VoiceCloseCodes) === VoiceCloseCodes.Disconnected ||
|
|
279
|
+
(code as VoiceCloseCodes) === VoiceCloseCodes.DisconnectedRateLimited ||
|
|
280
|
+
(code as VoiceCloseCodes) === VoiceCloseCodes.DisconnectedCallTerminated;
|
|
281
|
+
|
|
282
|
+
if (shouldDestroy) {
|
|
283
|
+
this.destroy(guildId, `Voice closed: ${reason} (${code})`).catch(noop);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Handle READY event
|
|
289
|
+
*/
|
|
290
|
+
#handleReady(_payload: BotReadyPayload): void {
|
|
291
|
+
// Clear all voice states on ready
|
|
292
|
+
this.#cache.clear();
|
|
293
|
+
this.#voices.clear();
|
|
294
|
+
this.#joins.clear();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Handle VOICE_STATE_UPDATE event
|
|
299
|
+
*/
|
|
300
|
+
#handleVoiceStateUpdate(payload: VoiceStateUpdatePayload): void {
|
|
301
|
+
const { d: data } = payload;
|
|
302
|
+
const guildId = data.guild_id;
|
|
303
|
+
|
|
304
|
+
if (!guildId || data.user_id !== this.player.clientId) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let state = this.#cache.get(guildId);
|
|
309
|
+
|
|
310
|
+
// User left voice channel
|
|
311
|
+
if (data.channel_id === null) {
|
|
312
|
+
if (state) {
|
|
313
|
+
state.channel_id = "";
|
|
314
|
+
state.connected = false;
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Create or update state
|
|
320
|
+
if (!state) {
|
|
321
|
+
state = {
|
|
322
|
+
channel_id: data.channel_id,
|
|
323
|
+
session_id: data.session_id,
|
|
324
|
+
deaf: data.deaf,
|
|
325
|
+
mute: data.mute,
|
|
326
|
+
self_deaf: data.self_deaf,
|
|
327
|
+
self_mute: data.self_mute,
|
|
328
|
+
suppress: data.suppress,
|
|
329
|
+
token: "",
|
|
330
|
+
endpoint: "",
|
|
331
|
+
connected: false,
|
|
332
|
+
node_session_id: "",
|
|
333
|
+
reconnecting: false,
|
|
334
|
+
region_id: "",
|
|
335
|
+
};
|
|
336
|
+
this.#cache.set(guildId, state);
|
|
337
|
+
} else {
|
|
338
|
+
state.channel_id = data.channel_id;
|
|
339
|
+
state.session_id = data.session_id;
|
|
340
|
+
state.deaf = data.deaf;
|
|
341
|
+
state.mute = data.mute;
|
|
342
|
+
state.self_deaf = data.self_deaf;
|
|
343
|
+
state.self_mute = data.self_mute;
|
|
344
|
+
state.suppress = data.suppress;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
void this.#tryConnect(guildId);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Handle VOICE_SERVER_UPDATE event
|
|
352
|
+
*/
|
|
353
|
+
#handleVoiceServerUpdate(payload: VoiceServerUpdatePayload): void {
|
|
354
|
+
const { d: data } = payload;
|
|
355
|
+
const guildId = data.guild_id;
|
|
356
|
+
|
|
357
|
+
let state = this.#cache.get(guildId);
|
|
358
|
+
|
|
359
|
+
if (!state) {
|
|
360
|
+
state = {
|
|
361
|
+
channel_id: "",
|
|
362
|
+
session_id: "",
|
|
363
|
+
deaf: false,
|
|
364
|
+
mute: false,
|
|
365
|
+
self_deaf: false,
|
|
366
|
+
self_mute: false,
|
|
367
|
+
suppress: false,
|
|
368
|
+
token: data.token,
|
|
369
|
+
endpoint: data.endpoint ?? "",
|
|
370
|
+
connected: false,
|
|
371
|
+
node_session_id: "",
|
|
372
|
+
reconnecting: false,
|
|
373
|
+
region_id: "",
|
|
374
|
+
};
|
|
375
|
+
this.#cache.set(guildId, state);
|
|
376
|
+
} else {
|
|
377
|
+
state.token = data.token;
|
|
378
|
+
state.endpoint = data.endpoint ?? "";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Extract region from endpoint
|
|
382
|
+
if (state.endpoint) {
|
|
383
|
+
const match = state.endpoint.match(VoiceRegionIdRegex);
|
|
384
|
+
if (match?.[1]) {
|
|
385
|
+
state.region_id = match[1];
|
|
386
|
+
|
|
387
|
+
// Create or get voice region
|
|
388
|
+
if (!this.regions.has(state.region_id)) {
|
|
389
|
+
this.regions.set(state.region_id, new VoiceRegion(this.player, state.region_id));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
void this.#tryConnect(guildId);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Try to establish voice connection
|
|
399
|
+
*/
|
|
400
|
+
async #tryConnect(guildId: string): Promise<void> {
|
|
401
|
+
const state = this.#cache.get(guildId);
|
|
402
|
+
const request = this.#joins.get(guildId);
|
|
403
|
+
|
|
404
|
+
if (!state || !request) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Check if we have all required data
|
|
409
|
+
if (!state.channel_id || !state.session_id || !state.token || !state.endpoint) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this.#joins.delete(guildId);
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
// Select node
|
|
417
|
+
const nodeName =
|
|
418
|
+
request.node ??
|
|
419
|
+
(state.region_id && this.regions.get(state.region_id)?.getRelevantNode()?.name) ??
|
|
420
|
+
this.player.nodes.relevant()[0]?.name;
|
|
421
|
+
|
|
422
|
+
if (!nodeName) {
|
|
423
|
+
throw new Error("No nodes available");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Create queue if needed
|
|
427
|
+
let queue = this.player.queues.get(guildId);
|
|
428
|
+
if (!queue) {
|
|
429
|
+
queue = await this.player.queues.create({
|
|
430
|
+
guildId,
|
|
431
|
+
voiceId: state.channel_id,
|
|
432
|
+
node: nodeName,
|
|
433
|
+
context: request.context,
|
|
434
|
+
...request.config,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Create voice state
|
|
439
|
+
const voice = new VoiceState(this.player, nodeName, guildId);
|
|
440
|
+
this.#voices.set(guildId, voice);
|
|
441
|
+
|
|
442
|
+
state.connected = true;
|
|
443
|
+
state.node_session_id = voice.node.sessionId ?? "";
|
|
444
|
+
|
|
445
|
+
this.player.emit("voiceConnect", voice);
|
|
446
|
+
request.resolve(voice);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
request.reject(error instanceof Error ? error : new Error(String(error)));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|