Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: core #5

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"devDependencies": {
"@hazmi35/eslint-config": "^11.0.0",
"@types/node": "^18.18.9",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"eslint": "^8.54.0",
Expand All @@ -35,5 +36,16 @@
"@hazmi35/eslint-config/typescript"
],
"rules": {}
},
"dependencies": {
"@discordjs/collection": "^2.0.0",
"@kirishima/rest": "workspace:^",
"@kirishima/ws": "workspace:^",
"@sapphire/async-queue": "^1.5.0",
"@sapphire/utilities": "^3.13.0",
"discord-api-types": "^0.37.65",
"lavalink-api-types": "^1.1.5",
"undici": "^5.28.2",
"ws": "^8.14.2"
}
}
63 changes: 63 additions & 0 deletions packages/core/src/Structures/Base/BasePlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { GatewayVoiceServerUpdateDispatch, GatewayVoiceStateUpdateDispatch } from 'discord-api-types/gateway/v9';
import { Snowflake } from 'discord-api-types/globals';
import { WebsocketOpEnum } from 'lavalink-api-types';
import { KirishimaPlayerOptions } from 'src/index.js';
import { KirishimaNode } from 'src/index.js';
import { createVoiceChannelJoinPayload } from 'src/index.js';
import { Kirishima } from 'src/index.js';

export class BasePlayer {
public get voiceState() {

Check failure on line 10 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
return this.node.voiceStates.get(this.options.guildId);
}

public get voiceServer() {

Check failure on line 14 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
return this.node.voiceServers.get(this.options.guildId);
}

public constructor(public options: KirishimaPlayerOptions, public kirishima: Kirishima, public node: KirishimaNode) { }

public async connect() {

Check failure on line 20 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
await this.kirishima.options.send(this.options, createVoiceChannelJoinPayload(this.options));
return this;
}

public async disconnect() {

Check failure on line 25 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
await this.kirishima.options.send(this.options, createVoiceChannelJoinPayload(this.options, true));
return this;
}

public async setServerUpdate(packet: GatewayVoiceServerUpdateDispatch) {

Check failure on line 30 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
this.node.voiceServers.set(packet.d.guild_id, packet.d);
return this.sendVoiceUpdate(packet.d.guild_id);
}

public async setStateUpdate(packet: GatewayVoiceStateUpdateDispatch) {

Check failure on line 35 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
if (packet.d.user_id !== this.kirishima.options.clientId) return;

if (packet.d.channel_id && packet.d.guild_id) {
this.node.voiceStates.set(packet.d.guild_id, packet.d);
return this.sendVoiceUpdate(packet.d.guild_id);
}

if (packet.d.guild_id) {
this.node.voiceServers.delete(packet.d.guild_id);
this.node.voiceStates.delete(packet.d.guild_id);
await this.connect();
}
}

public async sendVoiceUpdate(guildId: Snowflake) {

Check failure on line 50 in packages/core/src/Structures/Base/BasePlayer.ts

View workflow job for this annotation

GitHub Actions / test / lint

Missing return type on function
const voiceState = this.node.voiceStates.get(guildId);
const event = this.node.voiceServers.get(guildId);

if (event && voiceState) {
await this.node.ws.send({
op: WebsocketOpEnum.VOICE_UPDATE,
guildId,
sessionId: voiceState.session_id,
event
});
}
}
}
136 changes: 136 additions & 0 deletions packages/core/src/Structures/Kirishima.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
import { EventEmitter } from 'node:events';
import type { KirishimaNodeOptions, KirishimaOptions, KirishimaPlayerOptions, LoadTrackResponse } from '../typings/index.js';
import crypto from 'node:crypto';

import { KirishimaNode } from './KirishimaNode.js';
import { GatewayVoiceServerUpdateDispatch, GatewayVoiceStateUpdateDispatch } from 'discord-api-types/gateway/v9';
import { Collection } from '@discordjs/collection';
import { KirishimaPlayer } from './KirishimaPlayer.js';
import { Structure } from './Structure.js';

export class Kirishima extends EventEmitter {
public nodes: Collection<string, KirishimaNode> = new Collection();
public players?: Collection<string, KirishimaPlayer>;
public constructor(public options: KirishimaOptions) {
super();

if (typeof options.send !== 'function') throw Error('Send function must be present and must be a function.');

if (
typeof options.spawnPlayer !== 'function' ||
(typeof options.spawnPlayer === undefined && (typeof options.fetchPlayer !== 'function' || typeof options.fetchPlayer === undefined))

Check failure on line 22 in packages/core/src/Structures/Kirishima.ts

View workflow job for this annotation

GitHub Actions / test / lint

Unnecessary conditional, the types have no overlap

Check failure on line 22 in packages/core/src/Structures/Kirishima.ts

View workflow job for this annotation

GitHub Actions / test / lint

Invalid typeof comparison value

Check failure on line 22 in packages/core/src/Structures/Kirishima.ts

View workflow job for this annotation

GitHub Actions / test / lint

Unnecessary conditional, the types have no overlap
) {
this.players = new Collection();
options.spawnPlayer = this.defaultSpawnPlayerHandler.bind(this);
}

if (
typeof options.fetchPlayer !== 'function' ||
(typeof options.fetchPlayer === undefined && (typeof options.spawnPlayer !== 'function' || typeof options.spawnPlayer === undefined))
) {
options.fetchPlayer = this.defaultFetchPlayerHandler.bind(this);
}

if (!options.nodes.length) throw new Error('Nodes option must not a empty array');
}

public async initialize(clientId?: string) {
if (!clientId && !this.options.clientId) throw new Error('Invalid clientId provided');
if (clientId && !this.options.clientId) this.options.clientId = clientId;
if (this.options.plugins) {
for (const plugin of [...this.options.plugins.values()]) {
await plugin.load(this);
}
}
return this.setNodes(this.options.nodes);
}

public async setNodes(nodeOrNodes: KirishimaNodeOptions | KirishimaNodeOptions[]): Promise<Kirishima> {
const isArray = Array.isArray(nodeOrNodes);
if (isArray) {
for (const node of nodeOrNodes) {
const kirishimaNode = new (Structure.get('KirishimaNode'))(node, this);
await kirishimaNode.connect();
this.nodes.set((node.identifier ??= crypto.randomBytes(4).toString('hex')), kirishimaNode);
}
return this;
}
const kirishimaNode = new (Structure.get('KirishimaNode'))(nodeOrNodes, this);
await kirishimaNode.connect();
this.nodes.set((nodeOrNodes.identifier ??= crypto.randomBytes(4).toString('hex')), kirishimaNode);
return this;
}

public setClientName(clientName: string) {
this.options.clientName = clientName;
return this;
}

public setClientId(clientId: string) {
this.options.clientId = clientId;
return this;
}

public resolveNode(identifierOrGroup?: string) {
const resolveGroupedNode = this.nodes.filter((x) => x.connected).find((x) => x.options.group?.includes(identifierOrGroup!)!);
if (resolveGroupedNode) return resolveGroupedNode;
const resolveIdenfitierNode = this.nodes.filter((x) => x.connected).find((x) => x.options.identifier === identifierOrGroup);
if (resolveIdenfitierNode) return resolveIdenfitierNode;
return this.resolveBestNode().first();
}

public resolveBestNode() {
return this.nodes
.filter((x) => x.connected)
.sort((x, y) => {
const XLoad = x.stats?.cpu ? (x.stats.cpu.systemLoad / x.stats.cpu.cores) * 100 : 0;
const YLoad = y.stats?.cpu ? (y.stats.cpu.systemLoad / y.stats.cpu.cores) * 100 : 0;
return XLoad - YLoad;
});
}

public async resolveTracks(options: string | { source?: string | undefined; query: string }, node?: KirishimaNode): Promise<LoadTrackResponse> {
node ??= this.resolveNode();
const resolveTracks = await node!.rest.loadTracks(options);
if (resolveTracks?.tracks.length) resolveTracks.tracks = resolveTracks.tracks.map((x) => new (Structure.get('KirishimaTrack'))(x));
return resolveTracks as unknown as LoadTrackResponse;
}

public spawnPlayer(options: KirishimaPlayerOptions, node?: KirishimaNode) {
return this.options.spawnPlayer!(options.guildId, options, node ?? this.resolveNode()!);
}

public async handleVoiceServerUpdate(packet: GatewayVoiceServerUpdateDispatch) {
for (const node of [...this.nodes.values()]) {
await node.handleVoiceServerUpdate(packet);
}
}

public async handleVoiceStateUpdate(packet: GatewayVoiceStateUpdateDispatch) {
for (const node of [...this.nodes.values()]) {
await node.handleVoiceStateUpdate(packet);
}
}

public async handleRawPacket(t: 'VOICE_SERVER_UPDATE' | 'VOICE_STATE_UPDATE', packet: unknown) {
if (t === 'VOICE_STATE_UPDATE') {
await this.handleVoiceStateUpdate(packet as GatewayVoiceStateUpdateDispatch);
}
if (t === 'VOICE_SERVER_UPDATE') {
await this.handleVoiceServerUpdate(packet as GatewayVoiceServerUpdateDispatch);
}
}

private defaultSpawnPlayerHandler(guildId: string, options: KirishimaPlayerOptions, node: KirishimaNode) {
const player = this.players!.has(guildId);
if (player) return this.players!.get(guildId)!;
const kirishimaPlayer = new (Structure.get('KirishimaPlayer'))(options, this, node);
this.players!.set(guildId, kirishimaPlayer);
return kirishimaPlayer;
}

private defaultFetchPlayerHandler(guildId: string) {
return this.players!.get(guildId);
}
}
50 changes: 50 additions & 0 deletions packages/core/src/Structures/KirishimaFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
ChannelMixEqualizer,
DistortionEqualizer,
Equalizer,
KaraokeEqualizer,
LowPassEqualizer,
RotationEqualizer,
TimeScaleEqualizer,
TremoloEqualizer,
VibratoEqualizer
} from 'lavalink-api-types';

export class KirishimaFilter {
public volume: number | null;
public equalizer: Equalizer[] | null;
public karaoke: KaraokeEqualizer | null;
public timescale: TimeScaleEqualizer | null;
public tremolo: TremoloEqualizer | null;
public vibrato: VibratoEqualizer | null;
public rotation: RotationEqualizer | null;
public distortion: DistortionEqualizer | null;
public channelMix: ChannelMixEqualizer | null;
public lowPass: LowPassEqualizer | null;

public constructor(options?: KirishimaFilterOptions) {
this.volume = options?.volume ?? 1.0;
this.equalizer = options?.equalizer ?? null;
this.karaoke = options?.karaoke ?? null;
this.timescale = options?.timescale ?? null;
this.tremolo = options?.tremolo ?? null;
this.vibrato = options?.vibrato ?? null;
this.rotation = options?.rotation ?? null;
this.distortion = options?.distortion ?? null;
this.channelMix = options?.channelMix ?? null;
this.lowPass = options?.lowPass ?? null;
}
}

export interface KirishimaFilterOptions {
volume: number | null;
equalizer: Equalizer[] | null;
karaoke: KaraokeEqualizer | null;
timescale: TimeScaleEqualizer | null;
tremolo: TremoloEqualizer | null;
vibrato: VibratoEqualizer | null;
rotation: RotationEqualizer | null;
distortion: DistortionEqualizer | null;
channelMix: ChannelMixEqualizer | null;
lowPass: LowPassEqualizer | null;
}
117 changes: 117 additions & 0 deletions packages/core/src/Structures/KirishimaNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { WebSocket } from 'ws';
import { Gateway } from '@kirishima/ws';
import { REST } from '@kirishima/rest';
import type { KirishimaNodeOptions } from '../typings/index.js';
import type { Kirishima } from './Kirishima.js';
import { GatewayVoiceServerUpdateDispatch, GatewayVoiceStateUpdateDispatch } from 'discord-api-types/gateway/v9';
import { LavalinkStatsPayload, WebsocketOpEnum } from 'lavalink-api-types';
import { BasePlayer } from './Base/BasePlayer.js';
import { Collection } from '@discordjs/collection';
import { Snowflake } from 'discord-api-types/globals';

export class KirishimaNode {
public ws!: Gateway;
public rest!: REST;
public stats: LavalinkStatsPayload | undefined;
public reconnect: { attempts: number; timeout?: NodeJS.Timeout } = { attempts: 0 };
public voiceServers: Collection<Snowflake, GatewayVoiceServerUpdateDispatch['d']> = new Collection();
public voiceStates: Collection<Snowflake, GatewayVoiceStateUpdateDispatch['d']> = new Collection();
public constructor(public options: KirishimaNodeOptions, public kirishima: Kirishima) { }

public get connected() {
if (!this.ws) return false;
return this.ws.connection?.readyState === WebSocket.OPEN;
}

public async connect(): Promise<KirishimaNode> {
this.rest ??= new REST(`${this.options.url.endsWith('443') || this.options.secure ? 'https' : 'http'}://${this.options.url}`, {
Authorization: (this.options.password ??= 'youshallnotpass')
});
if (this.connected) return this;
const headers = {
Authorization: (this.options.password ??= 'youshallnotpass'),
'User-Id': this.kirishima.options.clientId!,
'Client-Name': (this.kirishima.options.clientName ??= `Kirishima NodeJS Lavalink Client (https://github.com/kirishima-ship/core)`)
};

// @ts-expect-error If you know how to fix this, please open a PR.
if (this.kirishima.options.node?.resumeKey) headers['Resume-Key'] = this.kirishima.options.node.resumeKey;
this.ws = new Gateway(`${this.options.url.endsWith('443') || this.options.secure ? 'wss' : 'ws'}://${this.options.url}`, headers);
await this.ws.connect();
this.ws.on('open', this.open.bind(this));
this.ws.on('message', this.message.bind(this));
this.ws.on('error', this.error.bind(this));
this.ws.on('close', this.close.bind(this));
return this;
}

public disconnect() {
this.ws.connection?.close(1000, 'Disconnected by user');
if (this.reconnect.timeout) clearTimeout(this.reconnect.timeout);
}

public open(gateway: Gateway) {
this.reconnect.attempts = 0;
if (this.kirishima.options.node?.resumeKey && this.kirishima.options.node.resumeTimeout) {
void this.ws.send({
op: WebsocketOpEnum.CONFIGURE_RESUMING,
key: this.kirishima.options.node.resumeKey,
timeout: this.kirishima.options.node.resumeTimeout
});
}
this.kirishima.emit('nodeConnect', this, gateway);
}

public close(gateway: Gateway, close: number) {
this.kirishima.emit('nodeDisconnect', this, gateway, close);
if (this.kirishima.options.node && this.kirishima.options.node.reconnectOnDisconnect) {
if (this.reconnect.attempts < (this.kirishima.options.node.reconnectAttempts ?? 3)) {
this.reconnect.attempts++;
this.kirishima.emit('nodeReconnect', this, gateway, close);
this.reconnect.timeout = setTimeout(() => {
void this.connect();
}, this.kirishima.options.node.reconnectInterval ?? 5000);
} else {
this.kirishima.emit('nodeReconnectFailed', this, gateway, close);
}
}
}

public error(gateway: Gateway, error: Error) {
this.kirishima.emit('nodeError', this, gateway, error);
}

public message(gateway: Gateway, raw: string) {
try {
const message = JSON.parse(raw);
this.kirishima.emit('nodeRaw', this, gateway, message);
if (message.op === WebsocketOpEnum.STATS) this.stats = message;
} catch (e) {
this.kirishima.emit('nodeError', this, gateway, e);
}
}

public toJSON() {
return {
identifier: this.options.identifier,
url: this.options.url,
secure: this.options.secure,
password: this.options.password,
group: this.options.group
};
}

public async handleVoiceServerUpdate(packet: GatewayVoiceServerUpdateDispatch) {
const player = (await this.kirishima.options.fetchPlayer!(packet.d.guild_id)) as BasePlayer;
if (player) {
await player.setServerUpdate(packet);
}
}

public async handleVoiceStateUpdate(packet: GatewayVoiceStateUpdateDispatch) {
const player = (await this.kirishima.options.fetchPlayer!(packet.d.guild_id!)) as BasePlayer;
if (player) {
await player.setStateUpdate(packet);
}
}
}
Loading
Loading