diff --git a/package-lock.json b/package-lock.json index 1ba2a94a..c80b0006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "5.0.0-dev", "license": "MIT", "dependencies": { - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^3.23.8" }, "devDependencies": { "@shipgirl/eslint-config": "^0.4.0", @@ -4534,6 +4535,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 4ac34ffc..c7424574 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "url": "https://github.com/shipgirlproject/Shoukaku.git" }, "dependencies": { - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^3.23.8" }, "devDependencies": { "@shipgirl/eslint-config": "^0.4.0", diff --git a/src/Constants.ts b/src/Constants.ts index d4f0ef25..e0bf41b8 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -1,3 +1,4 @@ +import * as z from 'zod'; import Info from '../package.json'; // eslint-disable-next-line import-x/no-cycle import { NodeOption, ShoukakuOptions } from './Shoukaku'; @@ -18,19 +19,28 @@ export enum VoiceState { SESSION_FAILED_UPDATE } -export enum OpCodes { +// export to allow compiler to determine shape +/** + * Websocket operation codes + * @see https://lavalink.dev/api/websocket#op-types + */ +export enum OpCodesEnum { PLAYER_UPDATE = 'playerUpdate', STATS = 'stats', EVENT = 'event', READY = 'ready' } +export const OpCodes = z.nativeEnum(OpCodesEnum); +export type OpCode = z.TypeOf; + export const Versions = { REST_VERSION: 4, WEBSOCKET_VERSION: 4 }; export const ShoukakuDefaults: Required = { + validate: false, resume: false, resumeTimeout: 30, resumeByLibrary: false, diff --git a/src/Shoukaku.ts b/src/Shoukaku.ts index e5101276..1606db4a 100644 --- a/src/Shoukaku.ts +++ b/src/Shoukaku.ts @@ -46,6 +46,10 @@ export interface NodeOption { } export interface ShoukakuOptions { + /** + * Whether to validate Lavalink responses (worse performance) + */ + validate?: boolean; /** * Whether to resume a connection on disconnect to Lavalink (Server Side) (Note: DOES NOT RESUME WHEN THE LAVALINK SERVER DIES) */ @@ -173,6 +177,7 @@ export class Shoukaku extends TypedEventEmitter { * @param connector A Discord library connector * @param nodes An array that conforms to the NodeOption type that specifies nodes to connect to * @param options Options to pass to create this Shoukaku instance + * @param options.validate Whether to validate Lavalink responses (worse performance) * @param options.resume Whether to resume a connection on disconnect to Lavalink (Server Side) (Note: DOES NOT RESUME WHEN THE LAVALINK SERVER DIES) * @param options.resumeTimeout Time to wait before lavalink starts to destroy the players of the disconnected client * @param options.resumeByLibrary Whether to resume the players by doing it in the library side (Client Side) (Note: TRIES TO RESUME REGARDLESS OF WHAT HAPPENED ON A LAVALINK SERVER) diff --git a/src/connectors/Connector.ts b/src/connectors/Connector.ts index 641c17fe..b7ecf33e 100644 --- a/src/connectors/Connector.ts +++ b/src/connectors/Connector.ts @@ -5,9 +5,11 @@ import { ServerUpdate, StateUpdatePartial } from '../guild/Connection'; import { NodeOption, Shoukaku } from '../Shoukaku'; import { mergeDefault } from '../Utils'; +export type AnyFunction = (...args: any[]) => any; + export interface ConnectorMethods { - sendPacket: any; - getId: any; + sendPacket: AnyFunction; + getId: AnyFunction; } export const AllowedPackets = [ 'VOICE_STATE_UPDATE', 'VOICE_SERVER_UPDATE' ]; diff --git a/src/guild/Connection.ts b/src/guild/Connection.ts index b0517d70..bc680bd9 100644 --- a/src/guild/Connection.ts +++ b/src/guild/Connection.ts @@ -1,26 +1,54 @@ import { EventEmitter, once } from 'node:events'; +import * as z from 'zod'; // eslint-disable-next-line import-x/no-cycle import { State, VoiceState } from '../Constants'; import { Shoukaku, VoiceChannelOptions } from '../Shoukaku'; /** * Represents the partial payload from a stateUpdate event + * @see https://discord.com/developers/docs/resources/voice#voice-state-object */ -export interface StateUpdatePartial { - channel_id?: string; - session_id?: string; - self_deaf: boolean; - self_mute: boolean; -} +export const StateUpdatePartial = z.object({ + /** + * Channel ID the state update is for + */ + channel_id: z.optional(z.string()), + /** + * Session ID the state update is for + */ + session_id: z.optional(z.string()), + /** + * Whether this user is locally deafened + */ + self_deaf: z.boolean(), + /** + * Whether this user is locally muted + */ + self_mute: z.boolean() +}); + +export type StateUpdatePartial = z.TypeOf; /** * Represents the payload from a serverUpdate event + * @see https://discord.com/developers/docs/topics/gateway-events#voice-server-update */ -export interface ServerUpdate { - token: string; - guild_id: string; - endpoint: string; -} +export const ServerUpdate = z.object({ + /** + * Voice connection token + */ + token: z.string(), + /** + * Guild this voice server update is for + */ + guild_id: z.string(), + /** + * Voice server hostname + */ + endpoint: z.string() +}); + +export type ServerUpdate = z.TypeOf; /** * Represents a connection to a Discord voice channel diff --git a/src/guild/Player.ts b/src/guild/Player.ts index 29a0d204..6cb1ec1f 100644 --- a/src/guild/Player.ts +++ b/src/guild/Player.ts @@ -1,3 +1,4 @@ +import * as z from 'zod'; // eslint-disable-next-line import-x/no-cycle import { OpCodes, State } from '../Constants'; // eslint-disable-next-line import-x/no-cycle @@ -7,124 +8,560 @@ import { Exception, Track, UpdatePlayerInfo, UpdatePlayerOptions } from '../node import { TypedEventEmitter } from '../Utils'; import { Connection } from './Connection'; -export type TrackEndReason = 'finished' | 'loadFailed' | 'stopped' | 'replaced' | 'cleanup'; +/** + * Why the track ended + * @see https://lavalink.dev/api/websocket#track-end-reason + */ +enum TrackEndReasonEnum { + /** + * Track finished playing, may start next track + */ + FINISHED = 'finished', + /** + * Track failed to load, may start next track + */ + LOAD_FAILED = 'loadFailed', + /** + * Track was stopped, will not start next track + */ + STOPPED = 'stopped', + /** + * Track was replaced, will not start next track + */ + REPLACED = 'replaced', + /** + * Track was cleaned up, will not start next track + */ + CLEANUP = 'cleanup' +} + +export const TrackEndReason = z.nativeEnum(TrackEndReasonEnum); +export type TrackEndReason = z.TypeOf; + export type PlayOptions = Omit; -export type ResumeOptions = Omit; +export type ResumeOptions = Omit & { playerState?: UpdatePlayerOptions }; -export enum PlayerEventType { +/** + * Type of event dispatched + * @see https://lavalink.dev/api/websocket.html#event-types + */ +enum PlayerEventTypeEnum { + /** + * Dispatched when a track starts playing + * @see https://lavalink.dev/api/websocket.html#trackstartevent + */ TRACK_START_EVENT = 'TrackStartEvent', + /** + * Dispatched when a track ends + * @see https://lavalink.dev/api/websocket.html#trackendevent + */ TRACK_END_EVENT = 'TrackEndEvent', + /** + * Dispatched when a track throws an exception + * @see https://lavalink.dev/api/websocket.html#trackexceptionevent + */ TRACK_EXCEPTION_EVENT = 'TrackExceptionEvent', + /** + * Dispatched when a track gets stuck while playing + * @see https://lavalink.dev/api/websocket.html#trackstuckevent + */ TRACK_STUCK_EVENT = 'TrackStuckEvent', + /** + * Dispatched when the websocket connection to Discord voice servers is closed + * @see https://lavalink.dev/api/websocket.html#websocketclosedevent + */ WEBSOCKET_CLOSED_EVENT = 'WebSocketClosedEvent' } -export interface Band { - band: number; - gain: number; -} +export const PlayerEventType = z.nativeEnum(PlayerEventTypeEnum); +export type PlayerEventType = z.TypeOf; -export interface KaraokeSettings { - level?: number; - monoLevel?: number; - filterBand?: number; - filterWidth?: number; -} +/** + * Equalizer configuration for each band + * @see https://lavalink.dev/api/rest.html#equalizer + */ +export const Band = z.object({ + /** + * The band, there are 15 bands (0-14) that can be changed + * + * 0 = 25 Hz + * + * 1 = 40 Hz + * + * 2 = 63 Hz + * + * 3 = 100 Hz + * + * 4 = 160 Hz + * + * 5 = 250 Hz + * + * 6 = 400 Hz + * + * 7 = 630 Hz + * + * 8 = 1000 Hz + * + * 9 = 1600 Hz + * + * 10 = 2500 Hz + * + * 11 = 4000 Hz + * + * 12 = 6300 Hz + * + * 13 = 10000 Hz + * + * 14 = 16000 Hz + */ + band: z.number().int().min(0).max(14), + /** + * Multiplier for each band. Valid values range from -0.25 to 1.00, + * where -0.25 means the given band is completely muted, + * and 0.25 means it is doubled. It can also chanege volume of output. + */ + gain: z.number().multipleOf(0.01).min(-0.25).max(1.00) +}); + +export type Band = z.TypeOf; -export interface TimescaleSettings { - speed?: number; - pitch?: number; - rate?: number; -} +/** + * Uses equalization to eliminate part of a band, usually targeting vocals + * @see https://lavalink.dev/api/rest.html#karaoke + */ +export const KaraokeSettings = z.object({ + /** + * Level, minimum 0.0 and maximum 1.0 where 0.0 is no effect and 1.0 is full effect + */ + level: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Mono level, minimum 0.0 and maximum 1.0 where 0.0 is no effect and 1.0 is full effect + */ + monoLevel: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Filter band (in Hz) + */ + filterBand: z.optional(z.number()), + /** + * Filter width + */ + filterWidth: z.optional(z.number()) +}); -export interface FreqSettings { - frequency?: number; - depth?: number; -} +export type KaraokeSettings = z.TypeOf; -export interface RotationSettings { - rotationHz?: number; -} +/** + * Changes the speed, pitch, and rate of playback + * @see https://lavalink.dev/api/rest.html#timescale + */ +export const TimescaleSettings = z.object({ + /** + * Playback speed, minimum to 0.0, default to 1.0 + */ + speed: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Pitch, minimum to 0.0, default to 1.0 + */ + pitch: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Rate, minimum to 0.0, default to 1.0 + */ + rate: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)) +}); -export interface DistortionSettings { - sinOffset?: number; - sinScale?: number; - cosOffset?: number; - cosScale?: number; - tanOffset?: number; - tanScale?: number; - offset?: number; - scale?: number; -} +export type TimescaleSettings = z.TypeOf; -export interface ChannelMixSettings { - leftToLeft?: number; - leftToRight?: number; - rightToLeft?: number; - rightToRight?: number; -} +/** + * Controls tremolo (wavering/shuddering) effect + * @see https://lavalink.dev/api/rest.html#tremolo + */ +export const TremoloSettings = z.object({ + /** + * Frequency, minimum 0.0 + */ + frequency: z.optional(z.number().multipleOf(0.1).min(0.0)), + /** + * Tremolo depth, minimum to 0.0, maximum 1.0 + */ + depth: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)) +}); -export interface LowPassSettings { - smoothing?: number; -} +export type TremoloSettings = z.TypeOf; -export interface PlayerEvent { - op: OpCodes.EVENT; - guildId: string; -} +/** + * Controls vibrato (rapid pitch change) effect + * @see https://lavalink.dev/api/rest.html#vibrato + */ +export const VibratoSettings = z.object({ + /** + * Frequency, minimum 0.0, maximum 14.0 + */ + frequency: z.optional(z.number().multipleOf(0.1).min(0.0).max(14.0)), + /** + * Vibrato depth, minimum to 0.0, maximum 1.0 + */ + depth: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)) +}); -export interface TrackStartEvent extends PlayerEvent { - type: PlayerEventType.TRACK_START_EVENT; - track: Track; -} +export type VibratoSettings = z.TypeOf; -export interface TrackEndEvent extends PlayerEvent { - type: PlayerEventType.TRACK_END_EVENT; - track: Track; - reason: TrackEndReason; -} +/** + * Rotates the sound around the stereo channels/user headphones (aka Audio Panning) + * @see https://lavalink.dev/api/rest.html#rotation + */ +export const RotationSettings = z.object({ + /** + * Frequency of the audio rotating around the listener in Hz + */ + rotationHz: z.number() +}); -export interface TrackStuckEvent extends PlayerEvent { - type: PlayerEventType.TRACK_STUCK_EVENT; - track: Track; - thresholdMs: number; -} +export type RotationSettings = z.TypeOf; -export interface TrackExceptionEvent extends PlayerEvent { - type: PlayerEventType.TRACK_EXCEPTION_EVENT; - exception: Exception; -} +/** + * Distortion effect + * @see https://lavalink.dev/api/rest.html#distortion + * @see https://github.com/lavalink-devs/lavadsp/blob/master/src/main/java/com/github/natanbc/lavadsp/distortion/DistortionConverter.java#L62 + */ +export const DistortionSettings = z.object({ + /** + * Sine offset + */ + sinOffset: z.optional(z.number()), + /** + * Sine scale (multiplier) + */ + sinScale: z.optional(z.number()), + /** + * Cosine offset + */ + cosOffset: z.optional(z.number()), + /** + * Cosine scale (multiplier) + */ + cosScale: z.optional(z.number()), + /** + * Tangent offset + */ + tanOffset: z.optional(z.number()), + /** + * Tangent scale (multiplier) + */ + tanScale: z.optional(z.number()), + /** + * Input offset + */ + offset: z.optional(z.number()), + /** + * Input scale (multiplier) + */ + scale: z.optional(z.number()) +}); -export interface WebSocketClosedEvent extends PlayerEvent { - type: PlayerEventType.WEBSOCKET_CLOSED_EVENT; - code: number; - byRemote: boolean; - reason: string; -} +export type DistortionSettings = z.TypeOf; -export interface PlayerUpdate { - op: OpCodes.PLAYER_UPDATE; - state: { - connected: boolean; - position: number; - time: number; - ping: number; - }; - guildId: string; -} +/** + * Mixes both channels (left and right), + * with a configurable factor on how much each channel affects the other, + * setting all factors 0.5 means both channels get the same audio + * @see https://lavalink.dev/api/rest.html#channel-mix + */ +export const ChannelMixSettings = z.object({ + /** + * Left to left channel mix factor, minimum 0.0, maximum 1.0 + */ + leftToLeft: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Left to right channel mix factor, minimum 0.0, maximum 1.0 + */ + leftToRight: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Right to left channel mix factor, minimum 0.0, maximum 1.0 + */ + rightToLeft: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)), + /** + * Right to right channel mix factor, minimum 0.0, maximum 1.0 + */ + rightToRight: z.optional(z.number().multipleOf(0.1).min(0.0).max(1.0)) +}); -export interface FilterOptions { - volume?: number; - equalizer?: Band[]; - karaoke?: KaraokeSettings | null; - timescale?: TimescaleSettings | null; - tremolo?: FreqSettings | null; - vibrato?: FreqSettings | null; - rotation?: RotationSettings | null; - distortion?: DistortionSettings | null; - channelMix?: ChannelMixSettings | null; - lowPass?: LowPassSettings | null; -} +export type ChannelMixSettings = z.TypeOf; + +/** + * Higher frequencies get suppressed, + * while lower frequencies pass through this filter, + * any smoothing values equal to or less than 1.0 will disable the filter + * @see https://lavalink.dev/api/rest.html#low-pass + */ +export const LowPassSettings = z.object({ + /** + * Smoothing factor, minimum 1.0 + */ + smoothing: z.optional(z.number().multipleOf(0.1).min(1.0)) +}); + +export type LowPassSettings = z.TypeOf; + +/** + * Server dispatched an event + * @see https://lavalink.dev/api/websocket.html#event-op + */ +export const PlayerEvent = z.object({ + /** + * Type of event + * @see {@link OpCodes} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#op-types + */ + op: z.literal(OpCodes.enum.EVENT), + /** + * Discord guild id + */ + guildId: z.string() +}); + +export type PlayerEvent = z.TypeOf; + +/** + * Dispatched when a track starts playing + * @see https://lavalink.dev/api/websocket.html#trackstartevent + */ +export const TrackStartEvent = PlayerEvent.extend({ + /** + * Player event type + * @see {@link PlayerEventType} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#event-types + */ + type: z.literal(PlayerEventType.enum.TRACK_START_EVENT), + /** + * Track that started playing + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#track + */ + track: Track +}); + +export type TrackStartEvent = z.TypeOf; + +export const TrackEndEvent = PlayerEvent.extend({ + /** + * Player event type + * @see {@link PlayerEventType} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#event-types + */ + type: z.literal(PlayerEventType.enum.TRACK_END_EVENT), + /** + * Track that ended playing + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#track + */ + track: Track, + /** + * Why the track ended + * @see {@link TrackEndReason} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#track-end-reason + */ + reason: TrackEndReason +}); + +export type TrackEndEvent = z.TypeOf; + +/** + * Dispatched when a track gets stuck while playing + * @see https://lavalink.dev/api/websocket#trackstuckevent + */ +export const TrackStuckEvent = PlayerEvent.extend({ + /** + * Player event type + * @see {@link PlayerEventType} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#event-types + */ + type: z.literal(PlayerEventType.enum.TRACK_STUCK_EVENT), + /** + * The track that got stuck + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#track + */ + track: Track, + /** + * Threshold in milliseconds that was exceeded + */ + thresholdMs: z.number().int() +}); + +export type TrackStuckEvent = z.TypeOf; + +/** + * Dispatched when a track throws an exception + * @see https://lavalink.dev/api/websocket#trackexceptionevent + */ +export const TrackExceptionEvent = PlayerEvent.extend({ + /** + * Player event type + * @see {@link PlayerEventType} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#event-types + */ + type: z.literal(PlayerEventType.enum.TRACK_EXCEPTION_EVENT), + /** + * The track that threw the exception + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#track + */ + track: Track, + /** + * Exception that occured + * @see {@link Exception} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#exception-object + */ + exception: Exception +}); + +export type TrackExceptionEvent = z.TypeOf; + +/** + * Dispatched when an audio WebSocket (to Discord) is closed, + * this can happen for various reasons (normal and abnormal), + * e.g. when using an expired voice server update, + * 4xxx codes are usually bad + * @see https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes + * @see https://lavalink.dev/api/websocket#websocketclosedevent + */ +export const WebSocketClosedEvent = PlayerEvent.extend({ + /** + * Player event type + * @see {@link PlayerEventType} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#event-types + */ + type: z.literal(PlayerEventType.enum.WEBSOCKET_CLOSED_EVENT), + /** + * Discord close event code + * @see https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes + */ + code: z.number().int(), + /** + * Whether the connection was closed by Discord + */ + byRemote: z.boolean(), + /** + * Why the connection was closed + */ + reason: z.string() +}); + +export type WebSocketClosedEvent = z.TypeOf; + +/** + * State of the player + * @see https://lavalink.dev/api/websocket.html#player-state + */ +export const PlayerState = z.object({ + /** + * Whether Lavalink is connected to the voice gateway + */ + connected: z.boolean(), + /** + * The position of the track in milliseconds + */ + position: z.number().int().min(0), + /** + * Unix timestamp in milliseconds + */ + time: z.number().int().min(0), + /** + * The ping of the node to the Discord voice server in milliseconds (-1 if not connected) + */ + ping: z.number().int().min(-1) +}); + +export type PlayerState = z.TypeOf; + +/** + * Dispatched by Lavalink at configured interval with the current state of the player + * @see https://lavalink.dev/api/websocket#player-update-op + */ +export const PlayerUpdate = z.object({ + /** + * Type of event + * @see {@link OpCodes} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#op-types + */ + op: z.literal(OpCodes.enum.PLAYER_UPDATE), + /** + * State of the player + * @see {@link PlayerState} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#player-state + */ + state: PlayerState, + /** + * Guild id of the player + */ + guildId: z.string() +}); + +export type PlayerUpdate = z.TypeOf; + +/** + * Lavalink filters + * @see https://lavalink.dev/api/rest.html#filters + */ +export const FilterOptions = z.object({ + /** + * Adjusts the player volume from 0.0 to 5.0, where 1.0 is 100%. Values >1.0 may cause clipping + */ + volume: z.optional(z.number().multipleOf(0.1).min(0.0).max(5.0)), + /** + * Adjusts 15 different bands + * @see https://lavalink.dev/api/rest.html#equalizer + */ + equalizer: z.optional(z.array(Band)).nullable(), + /** + * Eliminates part of a band, usually targeting vocals + * @see https://lavalink.dev/api/rest.html#karaoke + */ + karaoke: z.optional(KaraokeSettings).nullable(), + /** + * Changes the speed, pitch, and rate + * @see https://lavalink.dev/api/rest.html#timescale + */ + timescale: z.optional(TimescaleSettings).nullable(), + /** + * Creates a shuddering effect, where the volume quickly oscillates + * @see https://lavalink.dev/api/rest.html#tremolo + */ + tremolo: z.optional(TremoloSettings).nullable(), + /** + * Creates a shuddering effect, where the pitch quickly oscillates + * @see https://lavalink.dev/api/rest.html#vibrato + */ + vibrato: z.optional(VibratoSettings).nullable(), + /** + * Rotates the audio around the stereo channels/user headphones (aka Audio Panning) + * @see https://lavalink.dev/api/rest.html#rotation + */ + rotation: z.optional(RotationSettings).nullable(), + /** + * Distorts the audio + * @see https://lavalink.dev/api/rest.html#distortion + */ + distortion: z.optional(DistortionSettings).nullable(), + /** + * Mixes both channels (left and right) + * @see https://lavalink.dev/api/rest.html#channel-mix + */ + channelMix: z.optional(ChannelMixSettings).nullable(), + /** + * Filters higher frequencies + * @see https://lavalink.dev/api/rest.html#low-pass + */ + lowPass: z.optional(LowPassSettings).nullable(), + /** + * Plugins can add their own filters, the key is the name of the plugin, + * and the value is the configuration for that plugin + * @see https://lavalink.dev/api/rest.html#plugin-filters + */ + pluginFilters: z.optional(z.record(z.string(), z.record(z.string(), z.unknown()))).nullable() +}); + +export type FilterOptions = z.TypeOf; // Interfaces are not final, but types are, and therefore has an index signature // https://stackoverflow.com/a/64970740 @@ -175,64 +612,42 @@ export class Player extends TypedEventEmitter { * GuildId of this player */ public readonly guildId: string; + /** * Lavalink node this player is connected to */ public node: Node; + /** - * Base64 encoded data of the current track - */ - public track: string | null; - /** - * Global volume of the player - */ - public volume: number; - /** - * Pause status in current player - */ - public paused: boolean; - /** - * Ping represents the number of milliseconds between heartbeat and ack. Could be `-1` if not connected - */ - public ping: number; - /** - * Position in ms of current track - */ - public position: number; - /** - * Filters on current track + * Whether to validate Lavalink responses */ - public filters: FilterOptions; + private readonly validate: boolean; constructor(guildId: string, node: Node) { super(); this.guildId = guildId; this.node = node; - this.track = null; - this.volume = 100; - this.paused = false; - this.position = 0; - this.ping = 0; - this.filters = {}; + this.validate = this.node.manager.options.validate; } - public get data(): UpdatePlayerInfo { + public async data(): Promise { const connection = this.node.manager.connections.get(this.guildId)!; + const player = await this.node.rest.getPlayer(this.guildId); return { guildId: this.guildId, playerOptions: { track: { - encoded: this.track + encoded: player?.track?.encoded ?? null }, - position: this.position, - paused: this.paused, - filters: this.filters, + position: player?.state.position, + paused: player?.paused, + filters: player?.filters, voice: { token: connection.serverUpdate!.token, endpoint: connection.serverUpdate!.endpoint, sessionId: connection.sessionId! }, - volume: this.volume + volume: player?.volume } }; } @@ -349,7 +764,7 @@ export class Player extends TypedEventEmitter { * Change the tremolo settings applied to the currently playing track * @param tremolo An object that conforms to the FreqSettings type that defines an oscillation in volume */ - public setTremolo(tremolo?: FreqSettings): Promise { + public setTremolo(tremolo?: TremoloSettings): Promise { return this.setFilters({ tremolo: tremolo ?? null }); } @@ -357,7 +772,7 @@ export class Player extends TypedEventEmitter { * Change the vibrato settings applied to the currently playing track * @param vibrato An object that conforms to the FreqSettings type that defines an oscillation in pitch */ - public setVibrato(vibrato?: FreqSettings): Promise { + public setVibrato(vibrato?: VibratoSettings): Promise { return this.setFilters({ vibrato: vibrato ?? null }); } @@ -426,18 +841,20 @@ export class Player extends TypedEventEmitter { * @param noReplace Set it to true if you don't want to replace the currently playing track */ public async resume(options: ResumeOptions = {}, noReplace = false): Promise { - const data = this.data; + const data = options.playerState ?? (await this.data()).playerOptions; + + if (!data) throw new Error(`[Player] Unexpected state: Empty player data for guild ${this.guildId}.`); if (typeof options.position === 'number') - data.playerOptions.position = options.position; + data.position = options.position; if (typeof options.endTime === 'number') - data.playerOptions.endTime = options.endTime; + data.endTime = options.endTime; if (typeof options.paused === 'boolean') - data.playerOptions.paused = options.paused; + data.paused = options.paused; if (typeof options.volume === 'number') - data.playerOptions.volume = options.volume; + data.volume = options.volume; - await this.update(data.playerOptions, noReplace); + await this.update(data, noReplace); this.emit('resumed', this); } @@ -455,21 +872,6 @@ export class Player extends TypedEventEmitter { }; await this.node.rest.updatePlayer(data); - - if (!noReplace) this.paused = false; - - if (playerOptions.filters) { - this.filters = { ...this.filters, ...playerOptions.filters }; - } - - if (typeof playerOptions.track !== 'undefined') - this.track = playerOptions.track.encoded ?? null; - if (typeof playerOptions.paused === 'boolean') - this.paused = playerOptions.paused; - if (typeof playerOptions.volume === 'number') - this.volume = playerOptions.volume; - if (typeof playerOptions.position === 'number') - this.position = playerOptions.position; } /** @@ -478,10 +880,6 @@ export class Player extends TypedEventEmitter { */ public clean(): void { this.removeAllListeners(); - this.track = null; - this.volume = 100; - this.position = 0; - this.filters = {}; } /** @@ -506,10 +904,8 @@ export class Player extends TypedEventEmitter { * Handle player update data */ public onPlayerUpdate(json: PlayerUpdate): void { - const { position, ping } = json.state; - this.position = position; - this.ping = ping; - this.emit('update', json); + const data = this.validate ? PlayerUpdate.parse(json) : json; + this.emit('update', data); } /** @@ -519,21 +915,22 @@ export class Player extends TypedEventEmitter { */ public onPlayerEvent(json: TrackStartEvent | TrackEndEvent | TrackStuckEvent | TrackExceptionEvent | WebSocketClosedEvent): void { switch (json.type) { - case PlayerEventType.TRACK_START_EVENT: - if (this.track) this.track = json.track.encoded; - this.emit('start', json); + case PlayerEventType.enum.TRACK_START_EVENT: { + const data = this.validate ? TrackStartEvent.parse(json) : json; + this.emit('start', data); break; - case PlayerEventType.TRACK_END_EVENT: - this.emit('end', json); + } + case PlayerEventType.enum.TRACK_END_EVENT: + this.emit('end', this.validate ? TrackEndEvent.parse(json) : json); break; - case PlayerEventType.TRACK_STUCK_EVENT: - this.emit('stuck', json); + case PlayerEventType.enum.TRACK_STUCK_EVENT: + this.emit('stuck', this.validate ? TrackStuckEvent.parse(json) : json); break; - case PlayerEventType.TRACK_EXCEPTION_EVENT: - this.emit('exception', json); + case PlayerEventType.enum.TRACK_EXCEPTION_EVENT: + this.emit('exception', this.validate ? TrackExceptionEvent.parse(json) : json); break; - case PlayerEventType.WEBSOCKET_CLOSED_EVENT: - this.emit('closed', json); + case PlayerEventType.enum.WEBSOCKET_CLOSED_EVENT: + this.emit('closed', this.validate ? WebSocketClosedEvent.parse(json) : json); break; default: this.node.manager.emit( diff --git a/src/node/Node.ts b/src/node/Node.ts index 6e7f47e3..fcf8828d 100644 --- a/src/node/Node.ts +++ b/src/node/Node.ts @@ -1,5 +1,6 @@ import { IncomingMessage } from 'node:http'; import Websocket from 'ws'; +import * as z from 'zod'; // eslint-disable-next-line import-x/no-cycle import { OpCodes, ShoukakuClientInfo, State, Versions } from '../Constants'; // eslint-disable-next-line import-x/no-cycle @@ -9,71 +10,280 @@ import { TypedEventEmitter, wait } from '../Utils'; // eslint-disable-next-line import-x/no-cycle import { Rest } from './Rest'; -export interface Ready { - op: OpCodes.READY; - resumed: boolean; - sessionId: string; -} +/** + * Dispatched by Lavalink upon successful connection and authorization + * @see https://lavalink.dev/api/websocket.html#ready-op + */ +export const Ready = z.object({ + /** + * WebSocket OpCode + * @see {@link OpCodes} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#op-types + */ + op: z.literal(OpCodes.enum.READY), + /** + * Whether this session was resumed + */ + resumed: z.boolean(), + /** + * The Lavalink session id of this connection (not Discord voice session id) + */ + sessionId: z.string() +}); -export interface NodeMemory { - reservable: number; - used: number; - free: number; - allocated: number; -} +export type Ready = z.TypeOf; -export interface NodeFrameStats { - sent: number; - deficit: number; - nulled: number; -} +/** + * Memory statistics + * @see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html + * @see https://lavalink.dev/api/websocket#memory + */ +export const NodeMemory = z.object({ + /** + * Reservable memory in bytes + * @see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html#maxMemory() + */ + reservable: z.number().int().min(0), + /** + * Used memory in bytes + * + * totalMemory() - freeMemory() + */ + used: z.number().int().min(0), + /** + * Free memory in bytes + * @see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html#freeMemory() + */ + free: z.number().int().min(0), + /** + * Allocated memory in bytes + * @see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html#totalMemory() + */ + allocated: z.number().int().min(0) +}); -export interface NodeCpu { - cores: number; - systemLoad: number; - lavalinkLoad: number; -} +export type NodeMemory = z.TypeOf; -export interface Stats { - op: OpCodes.STATS; - players: number; - playingPlayers: number; - memory: NodeMemory; - frameStats: NodeFrameStats | null; - cpu: NodeCpu; - uptime: number; -} +/** + * Frame statistics + * @see https://lavalink.dev/api/websocket#frame-stats + */ +export const NodeFrameStats = z.object({ + /** + * Number of frames sent to Discord + */ + sent: z.number().int(), + /** + * Difference between number of sent frames and expected number of frames + * + * Expected amount of frames is 3000 (1 frame every 20 milliseconds) + * + * If negative, too many frames were sent, if positive, not enough frames were sent + */ + deficit: z.number().int(), + /** + * Number of frames nulled + */ + nulled: z.number().int() +}); -export interface NodeInfoVersion { - semver: string; - major: number; - minor: number; - patch: number; - preRelease?: string; - build?: string; -} +export type NodeFrameStats = z.TypeOf; -export interface NodeInfoGit { - branch: string; - commit: string; - commitTime: number; -} +/** + * CPU statistics + * @see https://lavalink.dev/api/websocket#cpu + */ +export const NodeCpu = z.object({ + /** + * Number of CPU cores available to JVM + * @see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html#availableProcessors() + */ + cores: z.number(), + /** + * Recent system CPU usage from load ticks + * @see https://www.oshi.ooo/oshi-core/apidocs/oshi/hardware/CentralProcessor.html#getSystemCpuLoadBetweenTicks(long%5B%5D) + */ + systemLoad: z.number(), + /** + * Recent Lavalink CPU usage from CPU time per core + */ + lavalinkLoad: z.number() +}); -export interface NodeInfoPlugin { - name: string; - version: string; -} +export type NodeCpu = z.TypeOf; -export interface NodeInfo { - version: NodeInfoVersion; - buildTime: number; - git: NodeInfoGit; - jvm: string; - lavaplayer: string; - sourceManagers: string[]; - filters: string[]; - plugins: NodeInfoPlugin[]; -} +/** + * Dispatched by Lavalink every minute when the node sends statistics + * @see https://lavalink.dev/api/websocket.html#stats-op + */ +export const Stats = z.object({ + /** + * WebSocket OpCode + * @see {@link OpCodes} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#op-types + */ + op: z.literal(OpCodes.enum.STATS), + /** + * Number of players connected to node + */ + players: z.number().int().min(0), + /** + * Number of players playing a track + */ + playingPlayers: z.number().int().min(0), + /** + * Node uptime in milliseconds + */ + uptime: z.number().int().min(0), + /** + * Memory statistics + * @see {@link NodeMemory} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#memory + */ + memory: NodeMemory, + /** + * CPU statistics + * @see {@link NodeCpu} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#cpu + */ + cpu: NodeCpu, + /** + * Frame statistics, null when node has no players or retrieved via /v4/stats endpoint + * @see {@link NodeFrameStats} representation in Shoukaku + * @see https://lavalink.dev/api/websocket#frame-stats + */ + frameStats: z.optional(NodeFrameStats) +}); + +export type Stats = z.TypeOf; + +/** + * Parsed Semantic Versioning 2.0.0 + * @see https://semver.org/spec/v2.0.0.html + * @see https://lavalink.dev/api/rest#version-object + */ +export const NodeInfoVersion = z.object({ + /** + * Full version string + */ + semver: z.string(), + /** + * Major version + */ + major: z.number().int(), + /** + * Minor version + */ + minor: z.number().int(), + /** + * Patch version + */ + patch: z.number().int(), + /** + * Prerelease version + */ + preRelease: z.optional(z.string()), + /** + * Build metadata + */ + build: z.optional(z.string()) +}); + +export type NodeInfoVersion = z.TypeOf; + +/** + * Lavalink Git information + * @see https://git-scm.com/book/en/v2/Git-Basics-Viewing-the-Commit-History + * @see https://lavalink.dev/api/rest#git-object + */ +export const NodeInfoGit = z.object({ + /** + * Git branch + */ + branch: z.string(), + /** + * Git commit hash + */ + commit: z.string(), + /** + * UNIX timestamp in miliseconds when Git commit was created + */ + commitTime: z.number().int().min(0) +}); + +export type NodeInfoGit = z.TypeOf; + +/** + * List of available plugins + * @see https://lavalink.dev/plugins + * @see https://lavalink.dev/api/rest#plugin-object + */ +export const NodeInfoPlugin = z.object({ + /** + * Plugin name + */ + name: z.string(), + /** + * Plugin version + */ + version: z.string() +}); + +export type NodeInfoPlugin = z.TypeOf; + +/** + * Node information + * @see https://lavalink.dev/api/rest.html#info-response + */ +export const NodeInfo = z.object({ + /** + * Lavalink server version + * @see {@link NodeInfoVersion} representation in Shoukaku + * @see https://semver.org/spec/v2.0.0.html + * @see https://lavalink.dev/api/rest.html#version-object + */ + version: NodeInfoVersion, + /** + * UNIX timestamp in miliseconds when Lavalink JAR was built + */ + buildTime: z.number().int().min(0), + /** + * Lavalink Git information + * @see {@link NodeInfoGit} representation in Shoukaku + * @see https://git-scm.com/book/en/v2/Git-Basics-Viewing-the-Commit-History + * @see https://lavalink.dev/api/rest#git-object + */ + git: NodeInfoGit, + /** + * JVM version string + * @see https://en.wikipedia.org/wiki/Java_version_history#Release_table + */ + jvm: z.string(), + /** + * Lavaplayer version string + * @see https://github.com/lavalink-devs/lavaplayer/releases + */ + lavaplayer: z.string(), + /** + * List of enabled source managers + * @see https://lavalink.dev/configuration/index.html + */ + sourceManagers: z.array(z.string()), + /** + * List of enabled filters + * @see https://lavalink.dev/configuration/index.html + */ + filters: z.array(z.string()), + /** + * List of available plugins + * @see {@link NodeInfoPlugin} representation in Shoukaku + * @see https://lavalink.dev/plugins + * @see https://lavalink.dev/api/rest#plugin-object + */ + plugins: z.array(NodeInfoPlugin) +}); + +export type NodeInfo = z.TypeOf; export interface ResumableHeaders { [key: string]: string; @@ -98,58 +308,77 @@ export class Node extends TypedEventEmitter { * Shoukaku class */ public readonly manager: Shoukaku; + /** * Lavalink rest API */ public readonly rest: Rest; + /** * Name of this node */ public readonly name: string; + /** * Group in which this node is contained */ public readonly group?: string; + /** * URL of Lavalink */ private readonly url: string; + /** * Credentials to access Lavalink */ private readonly auth: string; + /** * The number of reconnects to Lavalink */ + public reconnects: number; /** * The state of this connection */ public state: State; + /** * Statistics from Lavalink */ public stats: Stats | null; + /** * Information about lavalink node */ public info: NodeInfo | null; + /** * Websocket instance */ public ws: Websocket | null; + /** * SessionId of this Lavalink connection (not to be confused with Discord SessionId) */ public sessionId: string | null; + /** * Boolean that represents if the node has initialized once */ protected initialized: boolean; + /** * Boolean that represents if this connection is destroyed */ protected destroyed: boolean; + + /** + * Whether to validate Lavalink responses + */ + private readonly validate: boolean; + /** * @param manager Shoukaku instance * @param options Options on creating this node @@ -175,6 +404,7 @@ export class Node extends TypedEventEmitter { this.sessionId = null; this.initialized = false; this.destroyed = false; + this.validate = this.manager.options.validate; } /** @@ -229,7 +459,7 @@ export class Node extends TypedEventEmitter { this.emit('debug', `[Socket] -> [${this.name}] : Connecting to ${this.url} ...`); const url = new URL(this.url); - this.ws = new Websocket(url.toString(), { headers } as Websocket.ClientOptions); + this.ws = new Websocket(url.toString(), { headers }); this.ws.once('upgrade', response => this.open(response)); this.ws.once('close', (...args) => this.close(...args)); @@ -271,22 +501,24 @@ export class Node extends TypedEventEmitter { if (!json) return; this.emit('raw', json); switch (json.op) { - case OpCodes.STATS: + case OpCodes.enum.STATS: this.emit('debug', `[Socket] <- [${this.name}] : Node Status Update | Server Load: ${this.penalties}`); - this.stats = json; + this.stats = this.validate ? Stats.parse(json) : json; break; - case OpCodes.READY: { - if (!json.sessionId) { + case OpCodes.enum.READY: { + const data = this.validate ? Ready.parse(json) : json; + + if (!data.sessionId) { this.emit('debug', `[Socket] -> [${this.name}] : No session id found from ready op? disconnecting and reconnecting to avoid issues`); return this.internalDisconnect(1000); } - this.sessionId = json.sessionId; + this.sessionId = data.sessionId; const players = [ ...this.manager.players.values() ].filter(player => player.node.name === this.name); let resumedByLibrary = false; - if (!json.resumed && Boolean(this.initialized && (players.length && this.manager.options.resumeByLibrary))) { + if (!data.resumed && Boolean(this.initialized && (players.length && this.manager.options.resumeByLibrary))) { try { await this.resumePlayers(); resumedByLibrary = true; @@ -296,8 +528,8 @@ export class Node extends TypedEventEmitter { } this.state = State.CONNECTED; - this.emit('debug', `[Socket] -> [${this.name}] : Lavalink is ready! | Lavalink resume: ${json.resumed} | Lib resume: ${resumedByLibrary}`); - this.emit('ready', json.resumed, resumedByLibrary); + this.emit('debug', `[Socket] -> [${this.name}] : Lavalink is ready! | Lavalink resume: ${data.resumed} | Lib resume: ${resumedByLibrary}`); + this.emit('ready', data.resumed, resumedByLibrary); if (this.manager.options.resume) { await this.rest.updateSession(this.manager.options.resume, this.manager.options.resumeTimeout); @@ -306,14 +538,14 @@ export class Node extends TypedEventEmitter { break; } - case OpCodes.EVENT: - case OpCodes.PLAYER_UPDATE: { - const player = this.manager.players.get(json.guildId); + case OpCodes.enum.EVENT: + case OpCodes.enum.PLAYER_UPDATE: { + const player = 'guildId' in json && this.manager.players.get(json.guildId); if (!player) return; - if (json.op === OpCodes.EVENT) + if (json.op === OpCodes.enum.EVENT) player.onPlayerEvent(json); else - player.onPlayerUpdate(json); + player.onPlayerUpdate(this.validate ? PlayerUpdate.parse(json) : json); break; } default: diff --git a/src/node/Rest.ts b/src/node/Rest.ts index c590d18e..ee485616 100644 --- a/src/node/Rest.ts +++ b/src/node/Rest.ts @@ -1,148 +1,793 @@ -/* eslint-disable import-x/no-cycle */ +import * as z from 'zod'; +// eslint-disable-next-line import-x/no-cycle import { Versions } from '../Constants'; -import { FilterOptions } from '../guild/Player'; +// eslint-disable-next-line import-x/no-cycle +import { FilterOptions, PlayerState } from '../guild/Player'; import { NodeOption } from '../Shoukaku'; +// eslint-disable-next-line import-x/no-cycle import { Node, NodeInfo, Stats } from './Node'; -export type Severity = 'common' | 'suspicious' | 'fault'; +/** + * Severity of a Lavalink exception + * @see https://lavalink.dev/api/websocket.html#severity + */ +export enum SeverityEnum { + /** + * Cause is known and expected, indicates that there is nothing wrong with Lavalink + */ + COMMON = 'common', + /** + * Cause might not be exactly known, but is possibly caused by outside factors, + * for example when an outside service responds in a format Lavalink did not expect + */ + SUSPICIOUS = 'suspicious', + /** + * Probable cause is an issue with Lavalink or there is no way to tell what the cause might be, + * this is the default level and other levels are used in cases where the thrower has more in-depth knowledge about the error + */ + FAULT = 'fault' +} + +export const Severity = z.nativeEnum(SeverityEnum); +export type Severity = z.TypeOf; -export enum LoadType { +/** + * Type of resource loaded / status code + * @see https://lavalink.dev/api/rest.html#load-result-type + */ +enum LoadTypeEnum { + /** + * A track has been loaded + */ TRACK = 'track', + /** + * A playlist has been loaded + */ PLAYLIST = 'playlist', + /** + * A search result has been loaded + */ SEARCH = 'search', + /** + * There has been no matches for your identifier + */ EMPTY = 'empty', + /** + * Loading has failed with an error + */ ERROR = 'error' } -export interface Track { - encoded: string; - info: { - identifier: string; - isSeekable: boolean; - author: string; - length: number; - isStream: boolean; - position: number; - title: string; - uri?: string; - artworkUrl?: string; - isrc?: string; - sourceName: string; - }; - pluginInfo: unknown; -} +export const LoadType = z.nativeEnum(LoadTypeEnum); +export type LoadType = z.TypeOf; -export interface Playlist { - encoded: string; - info: { - name: string; - selectedTrack: number; - }; - pluginInfo: unknown; - tracks: Track[]; -} +/** + * Represents a Lavalink track + * @see https://lavalink.dev/api/rest.html#track + */ +export const Track = z.object({ + /** + * Base64 encoded track data + */ + encoded: z.string(), + /** + * Track information + * @see https://lavalink.dev/api/rest.html#track-info + */ + info: z.object({ + /** + * Track identifier + */ + identifier: z.string(), + /** + * Whether the track is seekable + */ + isSeekable: z.boolean(), + /** + * Track author + */ + author: z.string(), + /** + * Track length in milliseconds + */ + length: z.number().int().min(0), + /** + * Whether the track is a livestream + */ + isStream: z.boolean(), + /** + * Current playback time in milliseconds + */ + position: z.number().int().min(0), + /** + * Track title + */ + title: z.string(), + /** + * Track URI + */ + uri: z.string().optional(), + /** + * Track artwork url + */ + artworkUrl: z.string().optional(), + /** + * Track ISRC + * @see https://en.wikipedia.org/wiki/International_Standard_Recording_Code + */ + isrc: z.string().optional(), + /** + * The source this track was resolved from + */ + sourceName: z.string() + }), + /** + * Additional track info provided by plugins + */ + pluginInfo: z.record(z.unknown()), + /** + * Additional track data provided via the Update Player endpoint + * @see https://lavalink.dev/api/rest#update-player + */ + userData: z.record(z.unknown()) +}); -export interface Exception { - message: string; - severity: Severity; - cause: string; -} +export type Track = z.TypeOf; -export interface TrackResult { - loadType: LoadType.TRACK; - data: Track; -} +/** + * Represents a Lavalink playlist + * @see https://lavalink.dev/api/rest.html#playlist-result-data + */ +export const Playlist = z.object({ + /** + * Base64 encoded playlist data + */ + encoded: z.string(), + /** + * Playlist information + * @see https://lavalink.dev/api/rest.html#playlist-info + */ + info: z.object({ + /** + * Name of the playlist + */ + name: z.string(), + /** + * The selected track of the playlist (-1 if no track is selected) + */ + selectedTrack: z.number().int().min(-1) + }), + /** + * Additional track info provided by plugins + */ + pluginInfo: z.unknown(), + /** + * Tracks in the playlist + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#track + */ + tracks: z.array(Track) +}); -export interface PlaylistResult { - loadType: LoadType.PLAYLIST; - data: Playlist; -} +export type Playlist = z.TypeOf; -export interface SearchResult { - loadType: LoadType.SEARCH; - data: Track[]; -} +/** + * Represents a Lavalink exception (error) + */ +export const Exception = z.object({ + /** + * Message of the exception + */ + message: z.string(), + /** + * Severity of the exception + * @see {@link Severity} representation in Shoukaku + * @see https://lavalink.dev/api/websocket.html#severity + */ + severity: Severity, + /** + * Cause of the exception + */ + cause: z.string() +}); -export interface EmptyResult { - loadType: LoadType.EMPTY; - data: Record; -} +export type Exception = z.TypeOf; -export interface ErrorResult { - loadType: LoadType.ERROR; - data: Exception; -} +/** + * Track loading result when a track has been resolved + * @see https://lavalink.dev/api/rest.html#track-loading-result + */ +export const TrackResult = z.object({ + /** + * Type of the result + * @see {@link LoadType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#load-result-type + */ + loadType: z.literal(LoadType.enum.TRACK), + /** + * Track object with loaded track + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#load-result-data + */ + data: Track +}); -export type LavalinkResponse = TrackResult | PlaylistResult | SearchResult | EmptyResult | ErrorResult; +export type TrackResult = z.TypeOf; -export interface Address { - address: string; - failingTimestamp: number; - failingTime: string; -} +/** + * Track loading result when a playlist has been resolved + * @see https://lavalink.dev/api/rest.html#track-loading-result + */ +export const PlaylistResult = z.object({ + /** + * Type of the result + * @see {@link LoadType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#load-result-type + */ + loadType: z.literal(LoadType.enum.PLAYLIST), + /** + * Playlist object with loaded playlist + * @see {@link Playlist} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#playlist-result-data + */ + data: Playlist +}); -export interface RoutePlanner { - class: null | 'RotatingIpRoutePlanner' | 'NanoIpRoutePlanner' | 'RotatingNanoIpRoutePlanner' | 'BalancingIpRoutePlanner'; - details: null | { - ipBlock: { - type: string; - size: string; - }; - failingAddresses: Address[]; - rotateIndex: string; - ipIndex: string; - currentAddress: string; - blockIndex: string; - currentAddressIndex: string; - }; -} +export type PlaylistResult = z.TypeOf; -export interface LavalinkPlayerVoice { - token: string; - endpoint: string; - sessionId: string; - connected?: boolean; - ping?: number; -} +/** + * Track loading result when a search query has been resolved + * @see https://lavalink.dev/api/rest.html#track-loading-result + */ +export const SearchResult = z.object({ + /** + * Type of the result + * @see {@link LoadType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#load-result-type + */ + loadType: z.literal(LoadType.enum.SEARCH), + /** + * Array of Track objects from the search result + * @see {@link Track} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#search-result-data + */ + data: z.array(Track) +}); -export type LavalinkPlayerVoiceOptions = Omit; +export type SearchResult = z.TypeOf; -export interface LavalinkPlayer { - guildId: string; - track?: Track; - volume: number; - paused: boolean; - voice: LavalinkPlayerVoice; - filters: FilterOptions; -} +/** + * Track loading result when there is no result + * @see https://lavalink.dev/api/rest.html#track-loading-result + */ +export const EmptyResult = z.object({ + /** + * Type of the result + * @see {@link LoadType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#load-result-type + */ + loadType: z.literal(LoadType.enum.EMPTY), + /** + * An empty object + * @see https://lavalink.dev/api/rest.html#search-result-data + */ + data: z.record(z.never()) +}); -export interface UpdatePlayerTrackOptions { - encoded?: string | null; - identifier?: string; - userData?: unknown; -} +export type EmptyResult = z.TypeOf; -export interface UpdatePlayerOptions { - track?: UpdatePlayerTrackOptions; - position?: number; - endTime?: number; - volume?: number; - paused?: boolean; - filters?: FilterOptions; - voice?: LavalinkPlayerVoiceOptions; -} +/** + * Track loading result when an error has occured + * @see https://lavalink.dev/api/rest.html#track-loading-result + */ +export const ErrorResult = z.object({ + /** + * Type of the result + * @see {@link LoadType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#load-result-type + */ + loadType: z.literal(LoadType.enum.ERROR), + /** + * Exception object with the error + * @see {@link Exception} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#error-result-data + */ + data: Exception +}); + +export type ErrorResult = z.TypeOf; + +/** + * All possible responses on the track loading endpoint + * @see {@link TrackResult} - when track is loaded + * @see {@link PlaylistResult} - when playlist is loaded + * @see {@link SearchResult} - when search query is resolved + * @see {@link EmptyResult} - when nothing is loaded + * @see {@link ErrorResult} - when error occurs during load + * @see https://lavalink.dev/api/rest.html#track-loading-result + */ +export const LavalinkResponse = z.discriminatedUnion('loadType', [ + TrackResult, + PlaylistResult, + SearchResult, + EmptyResult, + ErrorResult +]); + +export type LavalinkResponse = z.TypeOf; -export interface UpdatePlayerInfo { - guildId: string; - playerOptions: UpdatePlayerOptions; - noReplace?: boolean; +/** + * Type of route planner + * @see https://lavalink.dev/api/rest.html#route-planner-types + */ +enum RoutePlannerTypeEnum { + /** + * IP address used is switched on ban. Recommended for IPv4 blocks or IPv6 blocks smaller than a /64. + */ + ROTATING_IP_ROUTE_PLANNER = 'RotatingIpRoutePlanner', + /** + * IP address used is switched on clock update. Use with at least 1 /64 IPv6 block. + */ + NANO_IP_ROUTE_PLANNER = 'NanoIpRoutePlanner', + /** + * IP address used is switched on clock update, rotates to a different /64 block on ban. Use with at least 2x /64 IPv6 blocks. + */ + ROTATING_NANO_IP_ROUTE_PLANNER = 'RotatingNanoIpRoutePlanner', + /** + * IP address used is selected at random per request. Recommended for larger IP blocks. + */ + BALANCING_IP_ROUTE_PLANNER = 'BalancingIpRoutePlanner' } -export interface SessionInfo { - resumingKey?: string; - timeout: number; +export const RoutePlannerType = z.nativeEnum(RoutePlannerTypeEnum); +export type RoutePlannerType = z.TypeOf; + +/** + * Type of IP block + * @see https://lavalink.dev/api/rest#ip-block-type + */ +enum IpBlockTypeEnum { + /** + * IPv4 block + * @see https://en.wikipedia.org/wiki/IPv4 + */ + IPV4 = 'Inet4Address', + /** + * IPv6 block + * @see https://en.wikipedia.org/wiki/IPv6 + */ + IPV6 = 'Inet6Address' } +export const IpBlockType = z.nativeEnum(IpBlockTypeEnum); +export type IpBlockType = z.TypeOf; + +/** + * IP block + * @see https://lavalink.dev/api/rest#ip-block-object + */ +export const IpBlock = z.object({ + /** + * Type of IP block + * @see {@link IpBlockType} representation in Shoukaku + * @see https://lavalink.dev/api/rest#ip-block-type + */ + type: IpBlockType, + /** + * Size of IP block (number of IPs) + */ + size: z.string() +}); + +export type IpBlock = z.TypeOf; + +/** + * Describes a failing IP Address + * @see https://lavalink.dev/api/rest#failing-address-object + */ +export const FailingAddress = z.object({ + /** + * The failing IP address + */ + failingAddress: z.string(), + /** + * UNIX timestamp when the IP address failed + */ + failingTimestamp: z.number().int().min(0), + /** + * Time when the IP address failed as a pretty string + * @see https://docs.oracle.com/javase/8/docs/api/java/util/Date.html#toString-- + */ + failingTime: z.string() +}); + +export type FailingAddress = z.TypeOf; + +/** + * Route planner response when using RotatingIpRoutePlanner + * @see https://lavalink.dev/api/rest.html#routeplanner-api + */ +export const RotatingIpRoutePlanner = z.object({ + /** + * Type of route planner + * @see {@link RoutePlannerType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#route-planner-types + */ + class: z.literal(RoutePlannerType.enum.ROTATING_IP_ROUTE_PLANNER), + /** + * Information about the route planner + * @see https://lavalink.dev/api/rest.html#details-object + */ + details: z.object({ + /** + * IP block being used + * @see {@link IpBlock} representation in Shoukaku + * @see https://lavalink.dev/api/rest#ip-block-object + */ + ipBlock: IpBlock, + /** + * Array of failing IP addresses + * @see {@link FailingAddress} representation in Shoukaku + * @see https://lavalink.dev/api/rest#failing-address-object + */ + failingAddresses: z.array(FailingAddress), + /** + * Number of IP rotations + */ + rotateIndex: z.string(), + /** + * Current offset in the block + */ + ipIndex: z.string(), + /** + * Current IP address being used + */ + currentAddress: z.string() + }) +}); + +export type RotatingIpRoutePlanner = z.TypeOf; + +/** + * Route planner response when using NanoIpRoutePlanner + * @see https://lavalink.dev/api/rest.html#routeplanner-api + */ +export const NanoIpRoutePlanner = z.object({ + /** + * Type of route planner + * @see {@link RoutePlannerType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#route-planner-types + */ + class: z.literal(RoutePlannerType.enum.NANO_IP_ROUTE_PLANNER), + /** + * Information about the route planner + * @see https://lavalink.dev/api/rest.html#details-object + */ + details: z.object({ + /** + * IP block being used + * @see {@link IpBlock} representation in Shoukaku + * @see https://lavalink.dev/api/rest#ip-block-object + */ + ipBlock: IpBlock, + /** + * Array of failing IP addresses + * @see {@link FailingAddress} representation in Shoukaku + * @see https://lavalink.dev/api/rest#failing-address-object + */ + failingAddresses: z.array(FailingAddress), + /** + * Current offset in the IP block + */ + currentAddressIndex: z.string() + }) +}); + +export type NanoIpRoutePlanner = z.TypeOf; + +/** + * Route planner response when using RotatingNanoIpRoutePlanner + * @see https://lavalink.dev/api/rest.html#routeplanner-api + */ +export const RotatingNanoIpRoutePlanner = z.object({ + /** + * Type of route planner + * @see {@link RoutePlannerType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#route-planner-types + */ + class: z.literal(RoutePlannerType.enum.ROTATING_NANO_IP_ROUTE_PLANNER), + /** + * Information about the route planner + * @see https://lavalink.dev/api/rest.html#details-object + */ + details: z.object({ + /** + * IP block being used + * @see {@link IpBlock} representation in Shoukaku + * @see https://lavalink.dev/api/rest#ip-block-object + */ + ipBlock: IpBlock, + /** + * Array of failing IP addresses + * @see {@link FailingAddress} representation in Shoukaku + * @see https://lavalink.dev/api/rest#failing-address-object + */ + failingAddresses: z.array(FailingAddress), + /** + * Current offset in the IP block + */ + currentAddressIndex: z.string(), + /** + * The information in which /64 block IPs are chosen, + * this number increases on each ban + */ + blockIndex: z.string() + }) +}); + +export type RotatingNanoIpRoutePlanner = z.TypeOf; + +/** + * Route planner response when using BalancingIpRoutePlanner + * @see https://lavalink.dev/api/rest.html#routeplanner-api + */ +export const BalancingIpRoutePlanner = z.object({ + /** + * Type of route planner + * @see {@link RoutePlannerType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#route-planner-types + */ + class: z.literal(RoutePlannerType.enum.BALANCING_IP_ROUTE_PLANNER), + /** + * Information about the route planner + * @see https://lavalink.dev/api/rest.html#details-object + */ + details: z.object({ + /** + * IP block being used + * @see {@link IpBlock} representation in Shoukaku + * @see https://lavalink.dev/api/rest#ip-block-object + */ + ipBlock: IpBlock, + /** + * Array of failing IP addresses + * @see {@link FailingAddress} representation in Shoukaku + * @see https://lavalink.dev/api/rest#failing-address-object + */ + failingAddresses: z.array(FailingAddress) + }) +}); + +export type BalancingIpRoutePlanner = z.TypeOf; + +export const NullRoutePlanner = z.object({ + /** + * Type of route planner + * @see {@link RoutePlannerType} representation in Shoukaku + * @see https://lavalink.dev/api/rest.html#route-planner-types + */ + class: z.null(), + /** + * Information about the route planner + * @see https://lavalink.dev/api/rest.html#details-object + */ + details: z.null() +}); + +export type NullRoutePlanner = z.TypeOf; + +/** + * RoutePlanner status + * @see https://lavalink.dev/api/rest.html#routeplanner-api + */ +export const RoutePlanner = z.discriminatedUnion('class', [ + RotatingIpRoutePlanner, + NanoIpRoutePlanner, + RotatingNanoIpRoutePlanner, + BalancingIpRoutePlanner, + NullRoutePlanner +]); + +export type RoutePlanner = z.TypeOf; + +/** + * Player voice state + * @see https://lavalink.dev/api/rest#voice-state + */ +export const LavalinkPlayerVoice = z.object({ + /** + * Discord voice token + */ + token: z.string(), + /** + * Discord voice server endpoint + */ + endpoint: z.string(), + /** + * Discord voice session id + */ + sessionId: z.string(), + /** + * Discord voice server connection status + */ + connected: z.boolean().optional(), + /** + * Discord voice server connection latency + */ + ping: z.number().int().optional() +}); + +export type LavalinkPlayerVoice = z.TypeOf; + +/** + * Player voice connection options + * @see https://lavalink.dev/api/rest.html#voice-state + */ +export const LavalinkPlayerVoiceOptions = LavalinkPlayerVoice.omit({ connected: true, ping: true }); + +export type LavalinkPlayerVoiceOptions = z.TypeOf; + +/** + * Represents a Lavalink player + * @see https://lavalink.dev/api/rest.html#player + */ +export const LavalinkPlayer = z.object({ + /** + * Guild id of the player + */ + guildId: z.string(), + /** + * Currently playing track + * @see {@link Track} representation in Shoukaku + */ + track: z.optional(Track), + /** + * Volume of the player, range 0-1000, in percentage + */ + volume: z.number().int().min(0).max(1000), + /** + * Whether the player is paused + */ + paused: z.boolean(), + /** + * State of the player + */ + state: PlayerState, + /** + * Voice state of the player + */ + voice: LavalinkPlayerVoice, + /** + * Filters used by the player + */ + filters: FilterOptions +}); + +export type LavalinkPlayer = z.TypeOf; + +export const EncodedUpdatePlayerTrackOptions = z.object({ + /** + * Base64 encoded track to play. null stops the current track + */ + encoded: z.string().nullable(), + identifier: z.undefined(), + /** + * Additional track data to be sent back in the track object + * @see https://lavalink.dev/api/rest#track + */ + userData: z.unknown().optional() +}); + +export type EncodedUpdatePlayerTrackOptions = z.TypeOf; + +export const IdentifierUpdatePlayerTrackOptions = z.object({ + encoded: z.undefined(), + /** + * Identifier of the track to play + */ + identifier: z.string(), + /** + * Additional track data to be sent back in the track object + * @see https://lavalink.dev/api/rest#track + */ + userData: z.unknown().optional() +}); + +export type IdentifierUpdatePlayerTrackOptions = z.TypeOf; + +/** + * Options for updating/creating the player's track + * @see https://lavalink.dev/api/rest.html#update-player-track + */ +export const UpdatePlayerTrackOptions = z.union([ + EncodedUpdatePlayerTrackOptions, + IdentifierUpdatePlayerTrackOptions +]); + +export type UpdatePlayerTrackOptions = z.TypeOf; + +/** + * Options for updating/creating the player + * @see https://lavalink.dev/api/rest.html#update-player + */ +export const UpdatePlayerOptions = z.object({ + /** + * Specification for a new track to load, as well as user data to set + * @see {@link UpdatePlayerTrackOptions} representation in Shoukaku + */ + track: z.optional(UpdatePlayerTrackOptions), + /** + * Track position in milliseconds + */ + position: z.number().int().min(0).optional(), + /** + * The track end time in milliseconds (must be > 0). null resets this if it was set previously + */ + endTime: z.number().int().min(0).nullable().optional(), + /** + * The player volume, in percentage, from 0 to 1000 + */ + volume: z.number().int().min(0).max(1000).optional(), + /** + * Whether the player is paused + */ + paused: z.boolean().optional(), + /** + * The new filters to apply, this will override all previously applied filters + * @see {@link FilterOptions} representation in Shoukaku + */ + filters: z.optional(FilterOptions), + /** + * Information required for connecting to Discord + */ + voice: z.optional(LavalinkPlayerVoiceOptions) +}); + +export type UpdatePlayerOptions = z.TypeOf; + +/** + * Options for updating/creating the player for a guild + */ +export const UpdatePlayerInfo = z.object({ + /** + * Discord guild ID + */ + guildId: z.string(), + /** + * Options for updating/creating the player + * @see {@link UpdatePlayerOptions} representation in Shoukaku + */ + playerOptions: UpdatePlayerOptions, + /** + * Whether to replace the current track with the new track, defaults to false + */ + noReplace: z.boolean().optional() +}); + +export type UpdatePlayerInfo = z.TypeOf; + +/** + * Session information + * @see https://lavalink.dev/api/rest.html#update-session + */ +export const SessionInfo = z.object({ + // TODO: figure out why this is here, doesnt exist in LL docs + /** + * Resuming key + */ + resumingKey: z.string().optional(), + /** + * Whether resuming is enabled for this session or not + */ + resuming: z.boolean(), + /** + * The timeout in seconds (default is 60s) + */ + timeout: z.number().int().min(0) +}); + +export type SessionInfo = z.TypeOf; + interface FetchOptions { endpoint: string; options: { @@ -167,16 +812,26 @@ interface FinalFetchOptions { export class Rest { /** * Node that initialized this instance + * + * @see {@link Node} */ protected readonly node: Node; + /** * URL of Lavalink */ protected readonly url: string; + /** * Credentials to access Lavalink */ protected readonly auth: string; + + /** + * Whether to validate Lavalink responses + */ + private readonly validate: boolean; + /** * @param node An instance of Node * @param options The options to initialize this rest class @@ -190,6 +845,7 @@ export class Rest { this.node = node; this.url = `${options.secure ? 'https' : 'http'}://${options.url}/v${Versions.REST_VERSION}`; this.auth = options.auth; + this.validate = this.node.manager.options.validate; } protected get sessionId(): string { @@ -199,59 +855,67 @@ export class Rest { /** * Resolve a track * @param identifier Track ID - * @returns A promise that resolves to a Lavalink response + * @returns A promise that resolves to a {@link LavalinkResponse} */ - public resolve(identifier: string): Promise { + public async resolve(identifier: string): Promise { const options = { endpoint: '/loadtracks', options: { params: { identifier }} }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return LavalinkResponse.parse(response); } /** * Decode a track * @param track Encoded track - * @returns Promise that resolves to a track + * @returns Promise that resolves to a {@link Track} */ - public decode(track: string): Promise { + public async decode(track: string): Promise { const options = { endpoint: '/decodetrack', options: { params: { track }} }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return Track.parse(response); } /** * Gets all the player with the specified sessionId - * @returns Promise that resolves to an array of Lavalink players + * @returns Promise that resolves to an array of {@link LavalinkPlayer | LavalinkPlayer} objects */ public async getPlayers(): Promise { const options = { endpoint: `/sessions/${this.sessionId}/players`, options: {} }; - return await this.fetch(options) ?? []; + const response = await this.fetch(options) ?? []; + if (!this.validate) return response; + return LavalinkPlayer.array().parse(response); } /** * Gets the player with the specified guildId - * @returns Promise that resolves to a Lavalink player + * @returns Promise that resolves to a {@link LavalinkPlayer} */ - public getPlayer(guildId: string): Promise { + public async getPlayer(guildId: string): Promise { const options = { endpoint: `/sessions/${this.sessionId}/players/${guildId}`, options: {} }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return LavalinkPlayer.parse(response); } /** * Updates a Lavalink player * @param data SessionId from Discord - * @returns Promise that resolves to a Lavalink player + * @returns Promise that resolves to a {@link LavalinkPlayer} */ - public updatePlayer(data: UpdatePlayerInfo): Promise { + public async updatePlayer(data: UpdatePlayerInfo): Promise { const options = { endpoint: `/sessions/${this.sessionId}/players/${data.guildId}`, options: { @@ -261,7 +925,9 @@ export class Rest { body: data.playerOptions as Record } }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return LavalinkPlayer.parse(response); } /** @@ -273,16 +939,16 @@ export class Rest { endpoint: `/sessions/${this.sessionId}/players/${guildId}`, options: { method: 'DELETE' } }; - await this.fetch(options); + return await this.fetch(options); } /** * Updates the session with a resume boolean and timeout * @param resuming Whether resuming is enabled for this session or not * @param timeout Timeout to wait for resuming - * @returns Promise that resolves to a Lavalink player + * @returns Promise that resolves to {@link SessionInfo} */ - public updateSession(resuming?: boolean, timeout?: number): Promise { + public async updateSession(resuming?: boolean, timeout?: number): Promise { const options = { endpoint: `/sessions/${this.sessionId}`, options: { @@ -291,36 +957,43 @@ export class Rest { body: { resuming, timeout } } }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return SessionInfo.parse(response); } /** * Gets the status of this node - * @returns Promise that resolves to a node stats response + * @returns Promise that resolves to a {@link Stats} object */ - public stats(): Promise { + public async stats(): Promise { const options = { endpoint: '/stats', options: {} }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return Stats.parse(response); } /** * Get routeplanner status from Lavalink - * @returns Promise that resolves to a routeplanner response + * @returns Promise that resolves to a {@link RoutePlanner} response */ - public getRoutePlannerStatus(): Promise { + public async getRoutePlannerStatus(): Promise { const options = { endpoint: '/routeplanner/status', options: {} }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return RoutePlanner.parse(response); } /** * Release blacklisted IP address into pool of IPs * @param address IP address + * @returns Promise that resolves to void */ public async unmarkFailedAddress(address: string): Promise { const options = { @@ -331,27 +1004,30 @@ export class Rest { body: { address } } }; - await this.fetch(options); + return await this.fetch(options); } /** * Get Lavalink info + * @returns Promise that resolves to {@link NodeInfo} */ - public getLavalinkInfo(): Promise { + public async getLavalinkInfo(): Promise { const options = { endpoint: '/info', options: { headers: { 'Content-Type': 'application/json' } } }; - return this.fetch(options); + const response = await this.fetch(options); + if (!this.validate) return response; + return NodeInfo.parse(response); } /** * Make a request to Lavalink * @param fetchOptions.endpoint Lavalink endpoint * @param fetchOptions.options Options passed to fetch - * @throws `RestError` when encountering a Lavalink error response + * @throws {@link RestError} when encountering a Lavalink error response * @internal */ protected async fetch(fetchOptions: FetchOptions) { @@ -404,20 +1080,32 @@ export class Rest { } } -interface LavalinkRestError { - timestamp: number; - status: number; - error: string; - trace?: string; - message: string; - path: string; -} +type LavalinkRestError = Omit; +/** + * Lavalink error response + * @see https://lavalink.dev/api/rest.html#error-responses + */ export class RestError extends Error { + /** + * Timestamp in milliseconds since the Unix epoch + */ public timestamp: number; + /** + * HTTP status code + */ public status: number; + /** + * HTTP status code message + */ public error: string; + /** + * Stacktrace (sent when trace query parameter is true) + */ public trace?: string; + /** + * Request path + */ public path: string; constructor({ timestamp, status, error, trace, message, path }: LavalinkRestError) {