diff --git a/package.json b/package.json index b97f3ba..bc5788b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "main": "./lib/index.js", "module": "./lib/index.js", "types": "./lib/index.d.ts", - "files": ["lib/**", "deps/**"], + "files": [ + "lib/**", + "deps/**" + ], "scripts": { "build": "npm run clean && tsc --outDir ./lib", "prepublishOnly": "npm run build", @@ -19,18 +22,18 @@ "author": "MARCROCK22", "license": "Apache-2.0", "dependencies": { - "magic-bytes.js": "^1.10.0", - "ws": "^8.18.0" + "magic-bytes.js": "^1.10.0" }, "lint-staged": { - "*.ts": ["biome check --write"] + "*.ts": [ + "biome check --write" + ] }, "devDependencies": { "@biomejs/biome": "1.8.3", "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", "@types/node": "^20.14.11", - "@types/ws": "^8.5.11", "husky": "^9.1.1", "lint-staged": "^15.2.7", "rimraf": "5.0.9", @@ -50,7 +53,13 @@ "bugs": { "url": "https://github.com/tiramisulabs/seyfert" }, - "keywords": ["api", "discord", "bots", "typescript", "botdev"], + "keywords": [ + "api", + "discord", + "bots", + "typescript", + "botdev" + ], "publishConfig": { "access": "public" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aba54f4..81d7718 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: magic-bytes.js: specifier: ^1.10.0 version: 1.10.0 - ws: - specifier: ^8.18.0 - version: 8.18.0 optionalDependencies: chokidar: specifier: ^3.6.0 @@ -40,9 +37,6 @@ importers: '@types/node': specifier: ^20.14.11 version: 20.14.11 - '@types/ws': - specifier: ^8.5.11 - version: 8.5.11 husky: specifier: ^9.1.1 version: 9.1.1 @@ -209,9 +203,6 @@ packages: '@types/node@20.14.11': resolution: {integrity: sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==} - '@types/ws@8.5.11': - resolution: {integrity: sha512-4+q7P5h3SpJxaBft0Dzpbr6lmMaqh0Jr2tbhJZ/luAwvD7ohSCniYkwz/pLxuT2h0EOa6QADgJj1Ko+TzRfZ+w==} - JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -874,18 +865,6 @@ packages: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1092,10 +1071,6 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/ws@8.5.11': - dependencies: - '@types/node': 20.14.11 - JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -1707,8 +1682,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 - ws@8.18.0: {} - y18n@5.0.8: {} yaml@2.4.5: {} diff --git a/src/cache/index.ts b/src/cache/index.ts index a6bcc71..2647e6a 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -645,6 +645,7 @@ export class Cache { } function createMember(name: string): APIGuildMember { return { + banner: null, avatar: 'xdxd', deaf: !false, flags: GuildMemberFlags.StartedHomeActions, diff --git a/src/client/client.ts b/src/client/client.ts index 3724042..d51f56a 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -185,10 +185,10 @@ export class Client extends BaseClient { for (const g of packet.d.guilds) { this.__handleGuilds?.add(g.id); } - await this.events?.execute(packet.t as never, packet, this as Client, shardId); this.botId = packet.d.user.id; this.applicationId = packet.d.application.id; this.me = Transformers.ClientUser(this, packet.d.user, packet.d.application) as never; + await this.events?.execute(packet.t as never, packet, this as Client, shardId); if ( !( this.__handleGuilds?.size && diff --git a/src/client/workerclient.ts b/src/client/workerclient.ts index 60341f1..6e56310 100644 --- a/src/client/workerclient.ts +++ b/src/client/workerclient.ts @@ -55,6 +55,7 @@ export class WorkerClient extends BaseClient { promises = new Map void; timeout: NodeJS.Timeout }>(); shards = new Map(); + private __setServicesCache?: boolean; declare options: WorkerClientOptions; @@ -85,6 +86,9 @@ export class WorkerClient extends BaseClient { }; }) { super.setServices(rest); + if (rest.cache) { + this.__setServicesCache = true; + } if (rest.handlers && 'events' in rest.handlers) { if (!rest.handlers.events) { this.events = undefined; @@ -114,16 +118,26 @@ export class WorkerClient extends BaseClient { name: `[Worker #${workerData.workerId}]`, }); - const adapter = new WorkerAdapter(workerData); - if (this.options.postMessage) { - adapter.postMessage = this.options.postMessage; + if (this.__setServicesCache) { + this.setServices({ + cache: { + disabledCache: this.options.disabledCache, + }, + }); + } else { + const adapter = new WorkerAdapter(workerData); + if (this.options.postMessage) { + adapter.postMessage = this.options.postMessage; + } + this.setServices({ + cache: { + adapter, + disabledCache: this.options.disabledCache, + }, + }); } - this.setServices({ - cache: { - adapter, - disabledCache: this.options.disabledCache, - }, - }); + + delete this.__setServicesCache; if (workerData.debug) { this.debugger = new Logger({ @@ -394,10 +408,10 @@ export class WorkerClient extends BaseClient { for (const g of packet.d.guilds) { this.__handleGuilds?.add(g.id); } - await this.events?.execute(packet.t as never, packet, this, shardId); this.botId = packet.d.user.id; this.applicationId = packet.d.application.id; this.me = Transformers.ClientUser(this, packet.d.user, packet.d.application) as never; + await this.events?.execute(packet.t as never, packet, this, shardId); if ( !( this.__handleGuilds?.size && diff --git a/src/structures/GuildMember.ts b/src/structures/GuildMember.ts index a99b83a..ffa43d3 100644 --- a/src/structures/GuildMember.ts +++ b/src/structures/GuildMember.ts @@ -195,14 +195,20 @@ export class GuildMember extends BaseGuildMember { avatarURL(options?: ImageOptions & { exclude?: false }): string; avatarURL(options?: ImageOptions & { exclude?: boolean }): string | null { if (!this.avatar) { - return options?.exclude ? null : this.user.avatarURL(); + return options?.exclude ? null : this.user.avatarURL(options); } return this.rest.cdn.guilds(this.guildId).users(this.id).avatars(this.avatar).get(options); } - bannerURL(options?: ImageOptions) { - return this.user.bannerURL(options); + bannerURL(options: ImageOptions & { exclude: true }): string | undefined | null; + bannerURL(options?: ImageOptions & { exclude?: false }): string | undefined; + bannerURL(options?: ImageOptions & { exclude?: boolean }): string | undefined | null { + if (!this.banner) { + return options?.exclude ? null : this.user.bannerURL(options); + } + + return this.rest.cdn.guilds(this.guildId).users(this.id).banners(this.banner).get(options); } async fetchPermissions(force = false) { diff --git a/src/types/payloads/guild.ts b/src/types/payloads/guild.ts index ad51ea7..6ad140d 100644 --- a/src/types/payloads/guild.ts +++ b/src/types/payloads/guild.ts @@ -674,6 +674,12 @@ export interface APIGuildMember { * See https://discord.com/developers/docs/resources/user#avatar-decoration-data-object */ avatar_decoration_data?: APIAvatarDecorationData | null; + /** + * The data for the member's guild banner + * + * See https://github.com/discord/discord-api-docs/discussions/4217 + */ + banner: null | string; } /** diff --git a/src/websocket/discord/basesocket.ts b/src/websocket/discord/basesocket.ts index d74158b..738404a 100644 --- a/src/websocket/discord/basesocket.ts +++ b/src/websocket/discord/basesocket.ts @@ -1,26 +1,58 @@ import { randomUUID } from 'node:crypto'; -import NodeWebSocket from 'ws'; +import { SeyfertWebSocket } from './socket/custom'; export class BaseSocket { - private internal: NodeWebSocket | WebSocket; + private internal: SeyfertWebSocket | WebSocket; + + ping?: () => Promise; constructor(kind: 'ws' | 'bun', url: string) { - this.internal = kind === 'ws' ? new NodeWebSocket(url) : new WebSocket(url); + this.internal = kind === 'ws' ? new SeyfertWebSocket(url) : new WebSocket(url); + + if (kind === 'ws') { + const ws = this.internal as SeyfertWebSocket; + this.ping = ws.waitPing.bind(ws); + ws.onpong = data => { + const promise = ws.__promises.get(data); + if (data) { + ws.__promises.delete(data); + promise?.resolve(); + } + }; + } else { + const ws = this.internal as WebSocket; + this.ping = () => { + return new Promise(res => { + const nonce = randomUUID(); + const start = performance.now(); + const listener = (data: Buffer) => { + if (data.toString() !== nonce) return; + //@ts-expect-error bun support + ws.removeListener('pong', listener); + res(performance.now() - start); + }; + //@ts-expect-error bun support + ws.on('pong', listener); + //@ts-expect-error bun support + ws.ping(nonce); + }); + }; + } } - set onopen(callback: NodeWebSocket['onopen']) { + set onopen(callback: SeyfertWebSocket['onopen']) { this.internal.onopen = callback; } - set onmessage(callback: NodeWebSocket['onmessage']) { + set onmessage(callback: SeyfertWebSocket['onmessage']) { this.internal.onmessage = callback; } - set onclose(callback: NodeWebSocket['onclose']) { + set onclose(callback: SeyfertWebSocket['onclose']) { this.internal.onclose = callback; } - set onerror(callback: NodeWebSocket['onerror']) { + set onerror(callback: SeyfertWebSocket['onerror']) { this.internal.onerror = callback; } @@ -28,26 +60,10 @@ export class BaseSocket { return this.internal.send(data); } - close(...args: Parameters) { - // @ts-expect-error + close(...args: Parameters) { return this.internal.close(...args); } - async ping() { - if (!('ping' in this.internal)) throw new Error('Unexpected: Method ping not implemented'); - return new Promise(res => { - const nonce = randomUUID(); - const start = performance.now(); - const listener = (data: Buffer) => { - if (data.toString() !== nonce) return; - (this.internal as NodeWebSocket).removeListener('pong', listener); - res(performance.now() - start); - }; - (this.internal as NodeWebSocket).on('pong', listener); - (this.internal as NodeWebSocket).ping(nonce); - }); - } - get readyState() { return this.internal.readyState; } diff --git a/src/websocket/discord/shard.ts b/src/websocket/discord/shard.ts index 7437cb9..1be3dee 100644 --- a/src/websocket/discord/shard.ts +++ b/src/websocket/discord/shard.ts @@ -1,6 +1,4 @@ import { inflateSync } from 'node:zlib'; -import type WS from 'ws'; -import { WebSocket, type CloseEvent, type ErrorEvent } from 'ws'; import { type MakeRequired, MergeOptions, type Logger } from '../../common'; import { properties } from '../constants'; import { DynamicBucket } from '../structures'; @@ -67,7 +65,7 @@ export class Shard { } get isOpen() { - return this.websocket?.readyState === WebSocket.OPEN; + return this.websocket?.readyState === 1 /*WebSocket.open*/; } get gatewayURL() { @@ -86,6 +84,7 @@ export class Shard { ping() { if (!this.websocket) return Promise.resolve(Number.POSITIVE_INFINITY); + //@ts-expect-error return this.websocket.ping(); } @@ -102,9 +101,11 @@ export class Shard { // biome-ignore lint/correctness/noUndeclaredVariables: /\ bun lol this.websocket = new BaseSocket(typeof Bun === 'undefined' ? 'ws' : 'bun', this.currentGatewayURL); - this.websocket!.onmessage = (event: WS.MessageEvent) => this.handleMessage(event); + this.websocket!.onmessage = ({ data }: { data: string }) => { + this.handleMessage(data); + }; - this.websocket!.onclose = (event: WS.CloseEvent) => this.handleClosed(event); + this.websocket!.onclose = (event: { code: number; reason: string }) => this.handleClosed(event); this.websocket!.onerror = (event: ErrorEvent) => this.debugger?.error(event); @@ -264,12 +265,11 @@ export class Shard { } } - protected async handleClosed(close: CloseEvent) { + protected async handleClosed(close: { code: number; reason: string }) { clearInterval(this.heart.nodeInterval); this.debugger?.warn( - `[Shard #${this.id}] ${ShardSocketCloseCodes[close.code] ?? GatewayCloseCodes[close.code] ?? close.code} (${ - close.code - })`, + `[Shard #${this.id}] ${ShardSocketCloseCodes[close.code] ?? GatewayCloseCodes[close.code] ?? close.code} (${close.code})`, + close.reason, ); switch (close.code) { @@ -309,14 +309,14 @@ export class Shard { } async close(code: number, reason: string) { - if (this.websocket?.readyState !== WebSocket.OPEN) { + if (!this.isOpen) { return this.debugger?.warn(`[Shard #${this.id}] Is not open`); } this.debugger?.warn(`[Shard #${this.id}] Called close`); this.websocket?.close(code, reason); } - protected handleMessage({ data }: WS.MessageEvent) { + protected handleMessage(data: string | Buffer) { if (data instanceof Buffer) { data = inflateSync(data); } diff --git a/src/websocket/discord/sharder.ts b/src/websocket/discord/sharder.ts index b964989..e6d226e 100644 --- a/src/websocket/discord/sharder.ts +++ b/src/websocket/discord/sharder.ts @@ -95,7 +95,7 @@ export class ShardManager extends Map { handlePayload: this.options.handlePayload, properties: this.options.properties, debugger: this.debugger, - compress: false, + compress: this.options.compress ?? false, presence: this.options.presence?.(shardId, -1), }); diff --git a/src/websocket/discord/socket/custom.ts b/src/websocket/discord/socket/custom.ts new file mode 100644 index 0000000..e10937d --- /dev/null +++ b/src/websocket/discord/socket/custom.ts @@ -0,0 +1,313 @@ +import { randomBytes, createHash, randomUUID } from 'node:crypto'; +import { request } from 'node:https'; +import type { Socket } from 'node:net'; +import { createInflate, inflateSync } from 'node:zlib'; + +export class SeyfertWebSocket { + socket?: Socket = undefined; + hostname: string; + path: string; + __stored: Buffer[] = []; + __opcode = 0; + __body: Buffer[] = []; + __promises = new Map< + string, + { + resolve: () => void; + reject: (reason?: any) => void; + } + >(); + __zlib?: ReturnType; + + constructor( + url: string, + public compress = true, + ) { + const urlParts = new URL(url); + this.hostname = urlParts.hostname || ''; + this.path = `${urlParts.pathname}${urlParts.search || ''}&encoding=json`; + if (compress) { + this.__zlib = createInflate(); + } + this.connect(); + } + + private connect() { + const key = randomBytes(16).toString('base64'); + const req = request({ + //discord gateway hostname + hostname: this.hostname, + path: this.path, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': key, + 'Sec-WebSocket-Version': '13', + }, + }); + + req.on('upgrade', (res, socket) => { + const hash = createHash('sha1').update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); + const accept = res.headers['sec-websocket-accept']; + if (accept !== hash) { + socket.end(() => { + this.onerror(new Error('Invalid sec-websocket-accept header')); + }); + return; + } + this.socket = socket; + + socket.on('readable', () => { + this.handleReadable(); + }); + + socket.on('error', err => { + this.onerror(err); + }); + this.onopen(); + }); + + req.end(); + } + + handleReadable() { + // Keep reading until no data, this is useful when two payloads merges. + while (this.socket?.readableLength) { + // Read length without consuming the buffer + let length = this.readBytes(1, 1) & 127; + const slice = length === 126 ? 4 : length === 127 ? 10 : 2; + // Check if frame/data is complete + if (this.socket.readableLength < slice) return; // Wait to next cycle if not + if (length > 125) { + // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 + // If length is 126/127, read extended payload length instead + // Equivalent to readUint32BE + length = this.readBytes(2, slice - 2); + } + // Read the frame, ignore data next to it, leave it to next `while` cycle + const frame = this.socket.read(slice + length) as Buffer | null; + if (!frame) return; + // Get fin (0 | 1) + const fin = frame[0] >> 7; + // Read opcode (continuation, text, binary, close, ping, pong) + let opcode = frame[0] & 15; + // Cut frame to get payload + let payload = frame.subarray(slice); + // If fin is 0, store the data and wait to next `while` cycle to be fin=1 + if (fin === 0) { + this.__stored.push(payload); + // Only store opcode when is not 0 (continuation) + if (opcode !== 0) { + this.__opcode = opcode; + } + return; + } + // When the message is from ws fragmentation, opcode of last message is 0 + // If so, merge all messages. + if (opcode === 0) { + this.__stored.push(payload); + payload = Buffer.concat(this.__stored); + opcode = this.__opcode; + // Reset body + this.__stored = []; + // Reset opcode, should set as -1? + this.__opcode = 0; + } + // Handle opcodes + this.handleEvent(payload, opcode); + } + } + + handleEvent(body: Buffer, opcode: number) { + switch (opcode) { + // text + case 0x1: + this.onmessage({ data: body.toString() }); + break; + // binary + case 0x2: + this.onmessage({ data: inflateSync(body, { info: true }).buffer.toString() }); + break; + // pong + case 0x9: + this.onping(body.toString()); + break; + // ping + case 0xa: + this.onpong(body.toString()); + break; + // close + case 0x8: + { + const code = body.readUInt16BE(0); + const reason = body.subarray(2).toString(); + this.onclose({ code, reason }); + } + break; + } + } + + send(data: string) { + this._write(Buffer.from(data), 1); + } + + private _write(buffer: Buffer, opcode: number) { + const length = buffer.length; + let frame; + // Kinda same logic as above, but client-side + if (length < 126) { + frame = Buffer.allocUnsafe(6 + length); + frame[1] = 128 + length; + } else if (length < 65536) { + frame = Buffer.allocUnsafe(8 + length); + frame[1] = 254; + frame[2] = (length >> 8) & 255; + frame[3] = length & 255; + } else { + frame = Buffer.allocUnsafe(14 + length); + frame[1] = 255; + frame.writeBigUint64BE(BigInt(length), 2); + } + frame[0] = 128 + opcode; + frame.writeUint32BE(0, frame.length - length - 4); + frame.set(buffer, frame.length - length); + this.socket?.write(frame); + } + + onping(_data: string) {} + + onpong(_data: string) {} + + onopen() {} + + onmessage(_payload: { data: string }) {} + + onclose(_close: { code: number; reason: string }) {} + + onerror(_err: unknown) {} + + close(code: number, reason: string) { + // alloc payload length + const buffer = Buffer.alloc(2 + Buffer.byteLength(reason)); + // gateway close code + buffer.writeUInt16BE(code, 0); + // reason + buffer.write(reason, 2); + // message, close opcode + this._write(buffer, 0x8); + this.socket?.end(); + } + + pong(data: string) { + //send pong opcode (10) + this._write(Buffer.from(data), 0xa); + } + + ping(data: string) { + //send ping opcode (9) + this._write(Buffer.from(data), 0x9); + } + + waitPing() { + const id = this.#randomUUID(); + let timeout: NodeJS.Timeout | undefined; + + const start = performance.now(); + this.ping(id); + + return new Promise((resolve, reject) => { + this.__promises.set(id, { + reject, + resolve, + }); + timeout = setTimeout(() => { + resolve(); + }, 60e3); + }) + .then(() => { + return performance.now() - start; + }) + .finally(() => { + clearTimeout(timeout); + }); + } + + #randomUUID(): string { + const id = randomUUID(); + if (this.__promises.has(id)) return this.#randomUUID(); + return id; + } + + get readyState() { + return ['opening', 'open', 'closed', 'closed'].indexOf(this.socket?.readyState ?? 'closed'); + } + + /** + * + * @param start Start calculating bytes from `start` + * @param bits Num of bits since `start` + * @returns + */ + + private readBytes(start: number, bits: number) { + // @ts-expect-error this is private, thanks nodejs + const readable = this.socket._readableState as + | { + bufferIndex: number; + buffer: Buffer[]; + } + | { + buffer: { + head: ReadableHeadData; + }; + }; + // Num of bit read + let bitIndex = 0; + // Num of bit read counting since start + let read = 0; + // Bytes value + let value = 0; + // Node v20 + if ('bufferIndex' in readable) { + // actual index of the buffer to read + let blockIndex = readable.bufferIndex; + // Buffer to read + let block; + while ((block = readable.buffer[blockIndex++])) { + for (let i = 0; i < block.length; i++) { + if (++bitIndex > start) { + value *= 256; // shift 8 bits (1 byte) `*= 256 is faster than <<= 8` + value += block[i]; // sum value to bits + // Read until read all bits + if (++read === bits) { + return value; + } + } + } + } + } /* Support for olders versions*/ else { + // readable.buffer is kinda a LinkedList + let head: ReadableHeadData | undefined = readable.buffer.head; + while (head) { + for (let i = 0; i < head.data.length; i++) { + if (++bitIndex > start) { + value *= 256; // shift 8 bits (1 byte) `*= 256 is faster than <<= 8` + value += head.data[i]; // sum value to bits + // Read until read all bits + if (++read === bits) { + return value; + } + } + } + // continue with next node + head = head.next; + } + } + throw new Error('Unexpected error, not enough bytes'); + } +} + +export type ReadableHeadData = { + next?: ReadableHeadData; + data: Buffer; +};