Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ node_modules/
# Build output
build/

package-lock.json
package-lock.json
.vscode
14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@
},
"homepage": "https://github.com/NotSugden/OrcusJS#readme",
"dependencies": {
"@types/node": "^14.6.2",
"@types/node-fetch": "^2.5.7",
"@discordjs/collection": "^0.1.6",
"discord-api-types": "^0.4.1",
"node-fetch": "^2.6.0",
"typescript": "^4.0.2"
"ws": "^7.3.1"
},
"devDependencies": {
"@types/node": "^14.10.0",
"@types/node-fetch": "^2.5.7",
"@types/ws": "^7.2.6",
"@typescript-eslint/eslint-plugin": "^3.10.1",
"@typescript-eslint/parser": "^3.10.1",
"eslint": "^7.7.0"
"eslint": "^7.7.0",
"typescript": "^4.0.2"
}
}
}
55 changes: 55 additions & 0 deletions src/client/Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { RequestManager } from './rest';
import Util, { Constants } from '../util';
import { EventEmitter } from 'events';
import { buildRoute, Route } from './rest/buildRoute';
import GatewayManager from './gateway/GatewayManager';
import { UserCacheManager } from '../managers';

type Partialize<T extends object> = {
[K in keyof T]?: T[K] extends object ? Partialize<T[K]> : T[K];
}

export default class Client extends EventEmitter {
public gatewayManager: GatewayManager;
public options: ClientOptions;
public requestManager: RequestManager;
public token!: string | null;
public users: UserCacheManager;

constructor(options: Partialize<ClientOptions> = {}) {
super();
this.options = Util.mergeDefaults(options, Constants.DEFAULT_CLIENT_OPTIONS);
Object.defineProperty(this,
'token', {
value: process.env.DISCORD_TOKEN ?? null,
writable: true
}
);
this.gatewayManager = new GatewayManager(this);
this.requestManager = new RequestManager(this);
this.users = new UserCacheManager(this);
}

/**
* @internal This is for internal use only
*/
public get api(): Route {
return buildRoute(this);
}

public async login(token = this.token): Promise<this> {
this.token = token;
await this.gatewayManager.connect();
return this;
}
}

export interface ClientOptions {
rest: {
requestOffset: number;
timeout: number | null;
},
gateway: {
largeThreshold: number;
}
}
241 changes: 241 additions & 0 deletions src/client/gateway/GatewayManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import Client from '../Client';
import * as WebSocket from 'ws';
import { Error } from '../../errors';
import { GATEWAY_VERSION, GatewayStatus } from '../../util/constants';
import { TextDecoder } from 'util';
import { EventEmitter } from 'events';
import * as EventHandlers from './handlers';
import { GatewayDispatchEvents, GatewayOPCodes, GatewayReadyDispatch, GatewayReceivePayload, GatewaySendPayload } from 'discord-api-types/v6';

const decoder = new TextDecoder();

const unpack = (data: WebSocket.Data) => {
if (data instanceof ArrayBuffer) data = new Uint8Array(data);
return JSON.parse(
typeof data === 'string' ? data : decoder.decode(<Buffer>data)
);
};

export default class GatewayManager extends EventEmitter {
private _expectedGuilds: Set<string> | null = null;
private _readyTimeout: NodeJS.Timeout | null = null;
private websocket: WebSocket | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatSequence: number | null = null;
private lastPing: Date | null = null;
private lastAck: Date | null = null;
private sessionID: string | null = null;

public client!: Client;
public status: GatewayStatus = GatewayStatus.NOT_CONNECTED;

constructor(client: Client) {
super();
Object.defineProperty(this, 'client', { value: client });
}

public get ping(): number | null {
if (!this.lastPing || !this.lastAck) return null;
return this.lastAck.getTime() - this.lastPing.getTime();
}

async connect(): Promise<void> {
const { token } = this.client;
if (!token) {
throw new Error('MISSING_TOKEN');
}
let url: string | null = null;
try {
const data = await this.client.api.gateway('bot').get<{ url: string }>();
url = data.url;
} catch (error) {
if (error.httpStatus === 401) {
throw new Error('INVALID_TOKEN');
}
throw error;
}
return new Promise((resolve, reject) => {
const removeListeners = () => {
this.off('ready', onReady);
this.off('disconnect', onClose);
};
const onReady = () => {
this.status = GatewayStatus.CONNECTED;
this.client.emit('ready');
resolve();
removeListeners();
};
const onClose = () => {
if (this.status === GatewayStatus.RECONNECTING) return;
reject();
removeListeners();
};
this.once('ready', onReady);
this.on('disconnect', onClose);

const socket = this.websocket = new WebSocket(`${url}?v=${GATEWAY_VERSION}&encoding=json`);
socket.onerror = (error) => {
this.client.emit('gatewayError', error);
};
socket.onopen = () => {
this.client.emit('debug', `Connecting to gateaway: ${url}`);
this.status = GatewayStatus.CONNECTING;
};
socket.onclose = event => {
this.client.emit('debug', [
'Gateway WebSocket closed',
`Reason: ${event.reason || 'Unknown Reason'}`,
`Code: ${event.code}`,
`Was Clean: ${event.wasClean}`
].join('\n'));
this.status = GatewayStatus.DISCONNECTED;
this.client.emit('gatewayDisconnect', event);
};
socket.onmessage = async data => {
try {
await this.onMessage(data.data);
} catch (error) {
this.client.emit('gatewayError', error);
}
};
});
}

public disconnect(code?: number): void {
if (!this.websocket) {
this.client.emit('warn', 'GatewayManager#disconnect was called before a connection was made.');
return;
}
this.client.emit('debug', 'Gateaway disconnect');
try {
this.websocket.close(code);
} catch { } // eslint-disable-line no-empty
if (this.heartbeatInterval) {
clearTimeout(this.heartbeatInterval);
this.heartbeatInterval = null;
}
this.status = GatewayStatus.DISCONNECTED;
this.emit('disconnect', code);
}

public send(data: GatewaySendPayload): Promise<void> {
if (!this.websocket) {
this.client.emit('warn', 'GatewayManager#send was called before a connection was made.');
return Promise.resolve();
}
return new Promise<void>(
(resolve, reject) => this.websocket!.send(JSON.stringify(data), error => {
if (error) reject(error);
else resolve();
})
);
}

private async onMessage(data: WebSocket.Data) {
const packet: GatewayReceivePayload = unpack(data);
this.client.emit('gatewayPacket', packet);
if (packet.op === GatewayOPCodes.Dispatch) {
this.emit(packet.t, packet.d);
if (packet.t in EventHandlers) {
// Haven't added all the handlers yet so this will generate error but it will be fines
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await EventHandlers[packet.t](this, packet.d);
}
if (packet.t === GatewayDispatchEvents.Ready) {
this._setupReady(packet.d);
} else if (packet.t === GatewayDispatchEvents.GuildCreate) {
if (this.status === GatewayStatus.WAITING_FOR_GUILDS) {
this._updateExpectedGuilds(packet.d.id);
} else {
this._expectedGuilds?.delete(packet.d.id);
if (this._expectedGuilds && !this._expectedGuilds.size) {
this._expectedGuilds = null;
}
}
}
} else if (packet.op === GatewayOPCodes.Heartbeat) {
this.heartbeatSequence = packet.d;
this.client.emit('debug', `Received heartbeat: ${packet.d} from the Gateway.`);
} else if (packet.op === GatewayOPCodes.Reconnect || packet.op === GatewayOPCodes.InvalidSession) {
if (packet.d) {
this.status = GatewayStatus.RECONNECTING;
}
this.disconnect();
await this.reconnect();
} else if (packet.op === GatewayOPCodes.HeartbeatAck) {
this.lastAck = new Date();
} else if (packet.op === GatewayOPCodes.Hello) {
this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), packet.d.heartbeat_interval);
await this.identify();
}
}

private reconnect() {
this.status = GatewayStatus.RECONNECTING;
this.client.emit('debug', 'Reconnecting to gateway.');
return this.connect();
}

private async sendHeartbeat() {
this.lastPing = new Date();
try {
await this.send({ op: GatewayOPCodes.Heartbeat, d: this.heartbeatSequence || 0 });
} catch (error) {
this.client.emit('gatewayError', error);
}
}

private identify() {
return this.send(this.sessionID ? {
op: GatewayOPCodes.Resume,
d: {
seq: this.heartbeatSequence!,
session_id: this.sessionID,
token: this.client.token!
}
} : {
op: GatewayOPCodes.Identify, d: {
properties: {
$browser: 'OrcusJS',
// https://github.com/discordjs/discord-api-types/pull/17 incorrect type
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
$device: 'OrcusJS',
$os: process.platform
},
token: this.client.token!,
large_threshold: this.client.options.gateway.largeThreshold
}
});
}

private _setupReady(data: GatewayReadyDispatch['d']) {
this._expectedGuilds = new Set(data.guilds.map(guild => guild.id));
this.sessionID = data.session_id;
this.status = GatewayStatus.WAITING_FOR_GUILDS;
this._setReadyTimeout();
}

private _updateExpectedGuilds(id: string) {
this._expectedGuilds!.delete(id);
if (!this._expectedGuilds!.size) {
this._expectedGuilds = null;
clearTimeout(this._readyTimeout!);
this._readyTimeout = null;
this.emit('ready');
} else {
this._setReadyTimeout();
}
}

private _setReadyTimeout() {
if (this._readyTimeout) {
clearTimeout(this._readyTimeout);
}
this._readyTimeout = setTimeout(() => {
this.emit('ready');
this.client.emit('debug', `Ready emitted with missing guilds ${[...this._expectedGuilds!].join(', ')}`);
}, 15e3);
}
}
7 changes: 7 additions & 0 deletions src/client/gateway/handlers/GUILD_CREATE.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { GatewayGuildCreateDispatch } from 'discord-api-types/v6';
import GatewayManager from '../GatewayManager';

export default (manager: GatewayManager, data: GatewayGuildCreateDispatch): void => {
// const { client } = manager;
console.log('Recieved guild:', data);
};
1 change: 1 addition & 0 deletions src/client/gateway/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as GUILD_CREATE } from './GUILD_CREATE';
3 changes: 3 additions & 0 deletions src/client/gateway/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {
default as GatewayManager
} from './GatewayManager';
35 changes: 6 additions & 29 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,6 @@
import { RequestManager } from '../rest';
import { Util, Constants } from '../util';
import { EventEmitter } from 'events';

export class Client extends EventEmitter {
private requestManager: RequestManager;

public token!: string | null;
public options: ClientOptions;

constructor(options: Partial<ClientOptions> = {}) {
super();
this.options = Util.mergeDefaults(options, Constants.DEFAULT_CLIENT_OPTIONS);
Object.defineProperty(this,
'token', {
value: process.env.DISCORD_TOKEN ?? null,
writable: true
}
);
this.requestManager = new RequestManager(this);
}
}

export interface ClientOptions {
rest: {
requestOffset: number;
timeout: number | null;
}
}
export {
default as Client,
ClientOptions
} from './Client';
export * from './gateway';
export * from './rest';
Loading