feat: new ws & member's banner (#232)

* feat: new ws & member's banner

* fix: comments
This commit is contained in:
MARCROCK22 2024-08-01 23:45:13 -04:00 committed by GitHub
parent 91bf30e4b3
commit 34216bd066
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 421 additions and 83 deletions

View File

@ -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"
},

27
pnpm-lock.yaml generated
View File

@ -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: {}

1
src/cache/index.ts vendored
View File

@ -645,6 +645,7 @@ export class Cache {
}
function createMember(name: string): APIGuildMember {
return {
banner: null,
avatar: 'xdxd',
deaf: !false,
flags: GuildMemberFlags.StartedHomeActions,

View File

@ -185,10 +185,10 @@ export class Client<Ready extends boolean = boolean> 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<true>, 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<true>, shardId);
if (
!(
this.__handleGuilds?.size &&

View File

@ -55,6 +55,7 @@ export class WorkerClient<Ready extends boolean = boolean> extends BaseClient {
promises = new Map<string, { resolve: (value: any) => void; timeout: NodeJS.Timeout }>();
shards = new Map<number, Shard>();
private __setServicesCache?: boolean;
declare options: WorkerClientOptions;
@ -85,6 +86,9 @@ export class WorkerClient<Ready extends boolean = boolean> 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<Ready extends boolean = boolean> 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<Ready extends boolean = boolean> 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 &&

View File

@ -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) {

View File

@ -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;
}
/**

View File

@ -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<number>;
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<number>(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<NodeWebSocket['close']>) {
// @ts-expect-error
close(...args: Parameters<SeyfertWebSocket['close']>) {
return this.internal.close(...args);
}
async ping() {
if (!('ping' in this.internal)) throw new Error('Unexpected: Method ping not implemented');
return new Promise<number>(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;
}

View File

@ -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);
}

View File

@ -95,7 +95,7 @@ export class ShardManager extends Map<number, Shard> {
handlePayload: this.options.handlePayload,
properties: this.options.properties,
debugger: this.debugger,
compress: false,
compress: this.options.compress ?? false,
presence: this.options.presence?.(shardId, -1),
});

View File

@ -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<typeof createInflate>;
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<void>((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;
};