From 08d4752eec1487304f4ee6ed2aabda17913456a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FAY=E3=82=B7?= <103030954+FAYStarNext@users.noreply.github.com> Date: Sun, 21 Jul 2024 18:20:09 +0700 Subject: [PATCH] chore: Update Queue class and RestEventEmitter interface --- package.json | 6 +- src/index.ts | 8 ++- src/structures/Manager.ts | 130 +++++++++++++++++++++++++++++++++++++- src/structures/Node.ts | 37 +++++++---- src/structures/Player.ts | 106 ++++++++++++++++++++++++++++++- src/structures/Queue.ts | 6 ++ src/structures/Rest.ts | 38 +++++++++-- src/types/Discord.ts | 24 +++++++ src/types/Manager.ts | 52 +++++++++++++++ src/types/Node.ts | 13 +++- src/types/Player.ts | 45 +++++++++++-- src/types/Rest.ts | 1 + test/manager.ts | 31 +++++++++ test/player.ts | 60 ++++++++++++++++++ 14 files changed, 529 insertions(+), 28 deletions(-) create mode 100644 src/structures/Queue.ts create mode 100644 src/types/Discord.ts create mode 100644 src/types/Manager.ts create mode 100644 test/manager.ts create mode 100644 test/player.ts diff --git a/package.json b/package.json index f92d36a..d8ec500 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "scripts": { "build:js": "npx babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline", "lint": "npx x eslint src/**/*.ts", - "test:connect": "bun test/connect.ts" + "test:connect": "bun test/connect.ts", + "test:manager": "bun test/manager.ts", + "test:player": "bun test/player.ts" }, "devDependencies": { "@eslint/js": "^9.7.0", @@ -30,7 +32,9 @@ "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/preset-env": "^7.24.8", "@babel/preset-typescript": "^7.24.7", + "@discordjs/collection": "^2.1.0", "axios": "^1.7.2", + "discord.js": "^14.15.3", "tiny-typed-emitter": "^2.1.0", "ws": "^8.18.0" } diff --git a/src/index.ts b/src/index.ts index 4f8c0fd..1d1e768 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ export * from "./structures/Node"; -export * from "./types/Node"; \ No newline at end of file +export * from "./types/Node"; +export * from "./structures/Manager"; +export * from "./types/Manager"; +export * from "./structures/Player"; +export * from "./types/Player"; +export * from "./structures/Rest"; +export * from "./types/Rest"; diff --git a/src/structures/Manager.ts b/src/structures/Manager.ts index 8ebc593..b696f8a 100644 --- a/src/structures/Manager.ts +++ b/src/structures/Manager.ts @@ -1,7 +1,133 @@ import { TypedEmitter } from "tiny-typed-emitter"; +import type { ManagerEventEmitter, ManagerOptions } from "../types/Manager"; +import { Node } from "./Node"; +import type { PlayerCreate } from "../types/Player"; +import { Player } from "./Player"; +import { Collection } from "@discordjs/collection"; +import type { VoiceState } from "discord.js"; +import type { VoicePacket, VoiceServer } from "../types/Discord"; -export class Manager extends TypedEmitter { - constructor() { +export class Manager extends TypedEmitter { + options: ManagerOptions; + public readonly players = new Collection(); + public readonly nodes = new Collection(); + constructor(options: ManagerOptions) { super(); + this.options = options; + for (const node of options.nodes) { + this.nodes.set(node.host, new Node(node)); + } } + + private get priorityNode(): Node { + const filteredNodes = this.nodes.filter((node) => node.connected && node.options.priority > 0); + const totalWeight = filteredNodes.reduce((total, node) => total + node.options.priority, 0); + const weightedNodes = filteredNodes.map((node) => ({ + node, + weight: node.options.priority / totalWeight, + })); + const randomNumber = Math.random(); + + let cumulativeWeight = 0; + + for (const { node, weight } of weightedNodes) { + cumulativeWeight += weight; + if (randomNumber <= cumulativeWeight) { + return node; + } + } + + return this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); + } + + private get leastLoadNode(): Collection { + return this.nodes + .filter((node) => node.connected) + .sort((a, b) => { + const aload = a.stats.cpu ? (a.stats.cpu.lavalinkLoad / a.stats.cpu.cores) * 100 : 0; + const bload = b.stats.cpu ? (b.stats.cpu.lavalinkLoad / b.stats.cpu.cores) * 100 : 0; + return aload - bload; + }); + } + + private get leastPlayersNode(): Collection { + return this.nodes.filter((node) => node.connected).sort((a, b) => a.stats.players - b.stats.players); + } + + public get useableNodes(): Node { + return this.options.usePriority ? this.priorityNode : this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); + } + + public create(options: PlayerCreate): Player { + if (this.players.has(options.guild_id)) { + return this.players.get(options.guild_id); + } + + return new Player(this, options); + } + + public init() { + this.emit("raw", "Manager initialized"); + for (const node of this.nodes.values()) { + try { + node.on("connect", () => { + this.emit("NodeConnect", node); + }).on("disconnect", () => { + this.emit("NodeDisconnect", node); + }).on("error", (err) => { + this.emit("NodeError", node, err); + }).on("stats", (stats) => { + this.emit("NodeStats", node, stats); + }).on("ready", () => { + this.emit("NodeReady", node); + }).on("raw", (data) => { + this.emit("NodeRaw", node, data); + }); + node.connect(); + } catch (err) { + this.emit("NodeError", node, err); + } + } + } + public async updateVoiceState(data: VoicePacket | VoiceServer | VoiceState): Promise { + if ("t" in data && !["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(data.t)) return; + const update = "d" in data ? data.d : data; + if (!update || (!("token" in update) && !("session_id" in update))) return; + const player = this.players.get(update.guild_id); + if (!player) return; + if ("token" in update) { + // @ts-ignore + if (!player.voiceState) player.voiceState = { event: {} }; + player.voiceState.event = update; + + const { + sessionId, + event: { token, endpoint }, + } = player.voiceState; + console.log(player.voiceState) + await player.node.rest.updatePlayer({ + guildId: player.guild_id, + data: { voice: { token, endpoint, sessionId } }, + }); + + return; + } + + if (update.user_id !== this.options.clientId) return; + if (update.channel_id) { + if (player.voiceChannel !== update.channel_id) { + this.emit("PlayerMove", player, player.voiceChannel, update.channel_id); + } + + player.voiceState.sessionId = update.session_id; + player.voiceChannel = update.channel_id; + return; + } + + this.emit("PlayerDisconnect", player, player.voiceChannel); + player.voiceChannel = null; + player.voiceState = Object.assign({}); + player.destroy(); + return; + } } \ No newline at end of file diff --git a/src/structures/Node.ts b/src/structures/Node.ts index 826be7b..ba4ac80 100644 --- a/src/structures/Node.ts +++ b/src/structures/Node.ts @@ -2,6 +2,7 @@ import { TypedEmitter } from "tiny-typed-emitter"; import { WebSocket } from "ws"; import type { NodeConfig, NodeEventEmitter, NodeStats } from "../types/Node"; import { Rest } from "./Rest"; +import type { Manager } from "./Manager"; /* This TypeScript class extends TypedEmitter with a generic type of NodeEventEmitter. */ export class Node extends TypedEmitter { @@ -23,11 +24,14 @@ export class Node extends TypedEmitter { property can hold a value of type `NodeStats` (defined elsewhere in the codebase) or it can be `null` if no value is assigned to it. */ public stats: NodeStats | null = null; + /* The line `session_id: string | null = null;` in the Node class is declaring a public property + named `session_id` with a type of `string` or `null`. This means that the `session_id` property + can hold a value of type `string`, representing a session identifier, or it can be `null` if no + value is assigned to it. */ session_id: string | null = null; /* The line `public rest: Rest | null = null;` in the Node class is declaring a public property named `rest` of type `Rest` or `null`. */ public rest: Rest | null = null; - /** * The function checks if a WebSocket connection is open and returns a boolean value accordingly. * @returns The `get connected` method returns a boolean value indicating whether the WebSocket @@ -38,6 +42,8 @@ export class Node extends TypedEmitter { if (!this.socket) return false; return this.socket.readyState === WebSocket.OPEN; } + /* The line `private static _manager: Manager;` in the Node class is declaring a private static + property named `_manager` with a type of `Manager`. */ /** * The constructor initializes a Rest object with the provided NodeConfig options. * @param {NodeConfig} options - The `options` parameter is an object that contains configuration @@ -47,11 +53,7 @@ export class Node extends TypedEmitter { constructor(options: NodeConfig) { super(); this.options = options; - this.rest = new Rest({ - host: options.host, - password: options.password, - port: options.port, - }) + this.rest = new Rest(this); } /** * The `connect` function establishes a WebSocket connection with specified headers and event @@ -63,18 +65,18 @@ export class Node extends TypedEmitter { if (this.connected) return; const headers = Object.assign({ "Authorization": this.options.password, - "Client-Name": "Lavalink", + "Client-Name": this.options.clientName || `Sunday.ts/${require("../../package.json").version}`, "User-Id": "213", }) this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.options.host}:${this.options.port}/v4/websocket`, { headers }); this.socket.on("open", () => { this.emit("connect"); }); - this.socket.on("close", () => { - console.log("Connection closed"); + this.socket.on("close", (data) => { + this.emit("disconnect", data); }); this.socket.on("error", (error) => { - console.error(error); + this.emit("error", error); }); this.socket.on("message", this.onMessage.bind(this)); } @@ -92,8 +94,16 @@ export class Node extends TypedEmitter { this.emit("raw", payload); switch (payload?.op) { case "ready": { - this.session_id = payload.session_id; - this.emit("ready"); + this.rest.setSessionId(payload.sessionId); + this.session_id = payload.sessionId; + + if (this.options.resumeStatus) { + this.rest.patch(`/v4/sessions/${this.session_id}`, { + resuming: this.options.resumeStatus, + timeout: this.options.resumeTimeout, + }); + } + this.emit("ready") break; } case "stats": { @@ -103,4 +113,7 @@ export class Node extends TypedEmitter { } } } + private debug(message: string) { + return this.emit("raw", message); + } } \ No newline at end of file diff --git a/src/structures/Player.ts b/src/structures/Player.ts index bfb96a1..0c8edfe 100644 --- a/src/structures/Player.ts +++ b/src/structures/Player.ts @@ -1,3 +1,107 @@ +import type { VoiceState } from "../types/Discord"; +import type { PlayerCreate } from "../types/Player"; +import type { Manager } from "./Manager"; +import type { Node } from "./Node"; +import { Queue } from "./Queue"; + export class Player { - + manager: Manager; + options: PlayerCreate; + node: Node; + voiceChannel: string; + state: "CONNECTING" | "CONNECTED" | "DISCONNECTED" | "DISCONNECTING" | "DESTROYING" = "DISCONNECTED"; + guild_id: string; + public voiceState: VoiceState; + paused: boolean = false; + playing: boolean = false; + textChannel: string + public readonly queue = new Queue(); + constructor(manager: Manager, options: PlayerCreate) { + this.manager = manager; + if (!this.manager) throw new RangeError("Manager has not been initiated."); + this.options = options; + this.voiceChannel = options.voiceChannel; + this.voiceState = Object.assign({ + op: "voiceUpdate", + guild_id: options.guild_id, + }); + if (options.voiceChannel) this.voiceChannel = options.voiceChannel; + if (options.textChannel) this.textChannel = options.textChannel; + const node = this.manager.nodes.get(options.node); + this.node = node || this.manager.useableNodes; + if (!this.node) throw new RangeError("No available nodes."); + this.guild_id = options.guild_id; + this.manager.players.set(options.guild_id, this); + this.manager.emit("PlayerCreate", this); + } + + public connect(): this { + if (!this.voiceChannel) throw new RangeError("No voice channel has been set."); + this.state = "CONNECTING"; + + this.manager.options.send(this.guild_id, { + op: 4, + d: { + guild_id: this.guild_id, + channel_id: this.voiceChannel, + self_mute: this.options.selfMute || false, + self_deaf: this.options.selfDeafen || false, + }, + }); + + this.state = "CONNECTED"; + return this; + } + public disconnect(): this { + if (this.voiceChannel === null) return this; + this.state = "DISCONNECTING"; + + this.pause(true); + this.manager.options.send(this.guild_id, { + op: 4, + d: { + guild_id: this.guild_id, + channel_id: null, + self_mute: false, + self_deaf: false, + }, + }); + + this.voiceChannel = null; + this.state = "DISCONNECTED"; + return this; + } + + /** Destroys the player. */ + public destroy(disconnect = true): void { + this.state = "DESTROYING"; + + if (disconnect) { + this.disconnect(); + } + + //this.node.rest.destroyPlayer(this.guild); + this.manager.emit("PlayerDestroy", this); + this.manager.players.delete(this.guild_id); + } + public pause(pause: boolean): this { + if (typeof pause !== "boolean") throw new RangeError('Pause can only be "true" or "false".'); + + if (this.paused === pause || !this.queue.totalSize) return this; + + const oldPlayer = { ...this }; + + this.playing = !pause; + this.paused = pause; + + this.node.rest.updatePlayer({ + guildId: this.guild_id, + data: { + paused: pause, + }, + }); + + this.manager.emit("PlayerStateUpdate", oldPlayer, this); + return this; + } } \ No newline at end of file diff --git a/src/structures/Queue.ts b/src/structures/Queue.ts new file mode 100644 index 0000000..9f57ea9 --- /dev/null +++ b/src/structures/Queue.ts @@ -0,0 +1,6 @@ +export class Queue { + totalSize: number = 0; + constructor() { + + } +} \ No newline at end of file diff --git a/src/structures/Rest.ts b/src/structures/Rest.ts index 0ba32df..7ae82d8 100644 --- a/src/structures/Rest.ts +++ b/src/structures/Rest.ts @@ -1,6 +1,8 @@ import { TypedEmitter } from "tiny-typed-emitter"; import type { RestConfig, RestEventEmitter } from "../types/Rest"; import axios, { type AxiosInstance } from "axios"; +import type { Node } from "./Node"; +import type { PlayOptions } from "../types/Player"; export class Rest extends TypedEmitter { /* The line `public req: AxiosInstance` in the TypeScript class `Rest` is declaring a public @@ -20,16 +22,25 @@ export class Rest extends TypedEmitter { * object of type `RestConfig`. It likely contains configuration options for making REST API * requests. Some of the properties in the `options` object could include: */ - constructor(options: RestConfig) { + /** The Node that this Rest instance is connected to. */ + private node: Node; + /** The ID of the current session. */ + private sessionId: string; + /** The password for the Node. */ + private readonly password: string; + /** The URL of the Node. */ + + constructor(node: Node) { super(); - this.config = options; - this.req = axios.create({ - baseURL: `http${options.secure ? "s" : ""}://${options.host}:${options.port}/v4`, + this.node = node; + this.req = axios.create({ + baseURL: `http${node.options.secure ? "s" : ""}://${node.options.host}:${node.options.port}/v4`, headers: { - "Authorization": options.password, + "Authorization": node.options.password } }); - } + this.password = node.options.password; + } /** * The function `get` sends a GET request to a specified path and emits events based on the response * or error. @@ -65,4 +76,19 @@ export class Rest extends TypedEmitter { this.emit("error", err); }); } + public async updatePlayer(options: PlayOptions): Promise { + return await this.patch(`/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); + } + public async patch(path: string, data: any) { + this.req.patch(path, data).then((res) => { + this.emit("patch", res.data); + return res.data; + }).catch((err) => { + this.emit("error", err); + }); + } + public setSessionId(sessionId: string): string { + this.sessionId = sessionId; + return this.sessionId; + } } \ No newline at end of file diff --git a/src/types/Discord.ts b/src/types/Discord.ts new file mode 100644 index 0000000..aec0e65 --- /dev/null +++ b/src/types/Discord.ts @@ -0,0 +1,24 @@ +export interface VoiceState { + op: "voiceUpdate"; + guildId: string; + event: VoiceServer; + sessionId?: string; +} + +export interface VoiceServer { + token: string; + guild_id: string; + endpoint: string; +} + +export interface VoiceState { + guild_id: string; + user_id: string; + session_id: string; + channel_id: string; +} + +export interface VoicePacket { + t?: "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATE"; + d: VoiceState | VoiceServer; +} \ No newline at end of file diff --git a/src/types/Manager.ts b/src/types/Manager.ts new file mode 100644 index 0000000..329775d --- /dev/null +++ b/src/types/Manager.ts @@ -0,0 +1,52 @@ +import type { Node } from "../structures/Node"; +import type { Player } from "../structures/Player"; +import type { NodeConfig, NodeStats } from "./Node"; + +/* The `ManagerEventEmitter` interface is defining a set of event listener functions that can be used +to handle various events related to nodes and players in a manager system. Each property in the +interface represents a specific event type along with the expected parameters and return type of the +event handler function. */ +interface ManagerEventEmitter { + NodeConnect: (node: Node) => void; + NodeDisconnect: (node: Node) => void; + NodeError: (node: Node, error: Error) => void; + NodeStats: (node: Node, stats: NodeStats) => void; + NodeReady: (node: Node) => void; + NodeRaw: (node: Node, data: unknown) => void; + PlayerCreate: (player: Player) => void; + PlayerDestroy: (player: Player) => void; + PlayerConnect: (player: Player) => void; + PlayerDisconnect: (player: Player, voiceChannel: string) => void; + PlayerError: (player: Player, error: Error) => void; + PlayerUpdate: (player: Player, state: unknown) => void; + PlayerVoiceUpdate: (player: Player, state: unknown) => void; + PlayerMove: (player: Player, oldChannel: string, newChannel: string) => void; + PlayerStateUpdate: (player: Player, state: unknown) => void; + raw: (data: unknown) => void; +} + +interface Payload { + op: number; + d: { + guild_id: string; + channel_id: string; + self_mute: boolean; + self_deaf: boolean; + } +} + +/* The `ManagerOptions` interface is defining a set of properties that can be used when creating an +instance of a manager. Here's what each property does: */ +interface ManagerOptions { + nodes: NodeConfig[]; + clientName?: string; + usePriority?: boolean; + clientId: string; + useNode?: "leastLoad" | "leastPlayers"; + send: (guild_id: string, payload: Payload) => void; +} + +export type { + ManagerEventEmitter, + ManagerOptions +} \ No newline at end of file diff --git a/src/types/Node.ts b/src/types/Node.ts index ace1dbc..bed411c 100644 --- a/src/types/Node.ts +++ b/src/types/Node.ts @@ -5,6 +5,8 @@ interface NodeEventEmitter { raw: (data: unknown) => void; stats: (stats: NodeStats) => void; connect: () => void; + error(error: Error): void; + disconnect: (data: unknown) => void; } /* The `interface NodeConfig` is defining a structure for configuring a Node. It specifies the @@ -16,13 +18,22 @@ interface NodeConfig { port: number; password: string; secure?: boolean; + clientName?: string; + version?: number; + priority?: number; + resumeStatus?: string; + resumeTimeout?: number; } /* The `interface NodeStats` is defining a structure for representing statistical data related to a Node. It includes various properties such as `frameStats`, `players`, `playingPlayers`, `uptime`, `memory`, and `cpu`. Each property has a specific data type associated with it: */ interface NodeStats { - frameStats: string | null, + frameStats: { + sent: number, + nulled: number, + deficit: number + }, players: number, playingPlayers: number, uptime: number, diff --git a/src/types/Player.ts b/src/types/Player.ts index 400d390..0010c82 100644 --- a/src/types/Player.ts +++ b/src/types/Player.ts @@ -1,7 +1,44 @@ -interface Player { - -} +import type { Node } from "../structures/Node"; +interface PlayerCreate { + guild_id: string; + textChannel: string; + voiceChannel: string; + volume?: number; + selfDeafen: boolean; + selfMute: boolean; + node?: string; +} +interface PlayOptions { + guildId: string; + data: { + /** The base64 encoded track. */ + encodedTrack?: string; + /** The track ID. */ + identifier?: string; + /** The track time to start at. */ + startTime?: number; + /** The track time to end at. */ + endTime?: number; + /** The player volume level. */ + volume?: number; + /** The player position in a track. */ + position?: number; + /** Whether the player is paused. */ + paused?: boolean; + /** The audio effects. */ + filters?: object; + /** voice payload. */ + voice?: { + token: string; + sessionId: string; + endpoint: string; + }; + /** Whether to not replace the track if a play payload is sent. */ + noReplace?: boolean; + }; +} export type { - + PlayerCreate, + PlayOptions } \ No newline at end of file diff --git a/src/types/Rest.ts b/src/types/Rest.ts index b3b6f43..59b7851 100644 --- a/src/types/Rest.ts +++ b/src/types/Rest.ts @@ -7,6 +7,7 @@ interface RestEventEmitter { get: (data: unknown) => void; error: (error: Error) => void; post: (data: unknown) => void; + patch: (data: unknown) => void; } /* The `interface RestConfig` in the TypeScript code snippet defines a structure for configuration diff --git a/test/manager.ts b/test/manager.ts new file mode 100644 index 0000000..1b11751 --- /dev/null +++ b/test/manager.ts @@ -0,0 +1,31 @@ +import { Manager } from "../src/structures/Manager"; + +let client = new Manager({ + nodes: [ + { + host: 'localhost', + port: 2333, + password: 'youshallnotpass', + }, + { + host: "ether.lunarnodes.xyz", + port: 6969, + password: "lunarnodes.xyz", + } + ], + clientId: "1234567890", + send(guild_id, payload) { + console.log(`Sending payload to guild ${guild_id}: ${JSON.stringify(payload)}`); + }, +}); + +client.on("NodeConnect", (node) => { + console.log(`Node ${node.options.host} connected`); +}); +client.on("NodeRaw", async (node, data) => { + console.log(`Node ${node.options.host} sent raw data: ${JSON.stringify(data)}`); +}); +client.on("raw", (data) => { + console.log(data); +}); +client.init() \ No newline at end of file diff --git a/test/player.ts b/test/player.ts new file mode 100644 index 0000000..06ec4da --- /dev/null +++ b/test/player.ts @@ -0,0 +1,60 @@ +import { Client } from "discord.js"; +import { Manager } from "../src/structures/Manager"; + +let client = new Client({ + intents: [ + "Guilds", + "GuildMembers", + "MessageContent", + "GuildMessages", + "GuildVoiceStates" + ], +}); +let manager = new Manager({ + nodes: [ + { + host: 'localhost', + port: 2333, + password: 'youshallnotpass', + }, + { + host: "ether.lunarnodes.xyz", + port: 6969, + password: "lunarnodes.xyz", + } + ], + clientId: "1234567890", + send(guild_id, payload) { + const guild = client.guilds.cache.get(guild_id); + if (guild) guild.shard.send(payload); + console.log(`Sending payload to guild ${guild_id}: ${JSON.stringify(payload)}`); + }, +}); + +manager.on("NodeConnect", (node) => { + console.log(`Node ${node.options.host} connected`); +}); +manager.on("NodeRaw", async (node, data) => { + console.log(`Node ${node.options.host} sent raw data: ${JSON.stringify(data)}`); +}); +manager.on("raw", (data) => { + console.log(data); +}); +client.on("ready", () => { + manager.init(); +}); +manager.on("PlayerCreate", (player) => { + console.log(`Player created in guild ${player.guild_id}`); +}); +client.on("messageCreate", (message) => { + console.log(message.content) + message.content === "message" && manager.create({ + voiceChannel: message.member?.voice.channel?.id as string, + textChannel: message.channel.id, + guild_id: message.guild?.id as string, + selfDeafen: true, + selfMute: false, + }).connect(); +}) +client.on("raw", (data) => manager.updateVoiceState(data)); +client.login(""); \ No newline at end of file