From 62772f6c8d2290e9368ca0f6dc841d3b7c3b0648 Mon Sep 17 00:00:00 2001 From: NotSugden <28943913+NotSugden@users.noreply.github.com> Date: Mon, 31 Aug 2020 19:13:06 +0100 Subject: [PATCH 1/3] feat: RequestManager wrapper --- src/client/index.ts | 7 ++++- src/rest/APIError.ts | 8 ++--- src/rest/RequestManager.ts | 12 +++---- src/rest/buildRoute.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/util/constants.ts | 8 +++++ 5 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 src/rest/buildRoute.ts diff --git a/src/client/index.ts b/src/client/index.ts index 2d83a31..b1e0292 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,9 +1,10 @@ import { RequestManager } from '../rest'; import { Util, Constants } from '../util'; import { EventEmitter } from 'events'; +import { buildRoute, Route } from '../rest/buildRoute'; export class Client extends EventEmitter { - private requestManager: RequestManager; + public requestManager: RequestManager; public token!: string | null; public options: ClientOptions; @@ -19,6 +20,10 @@ export class Client extends EventEmitter { ); this.requestManager = new RequestManager(this); } + + public get api(): Route { + return buildRoute(this); + } } export interface ClientOptions { diff --git a/src/rest/APIError.ts b/src/rest/APIError.ts index 80ca9d1..9732193 100644 --- a/src/rest/APIError.ts +++ b/src/rest/APIError.ts @@ -12,14 +12,14 @@ export default class APIError extends Error { ) { super(); this.name = 'APIError'; - this.message = APIError.resolveError(data.errors); - this.code = data.code; + this.message = data.errors ? APIError.resolveError(data.errors) : data.message; + this.code = data.code; this.endpoint = endpoint; this.bucket = bucket; this.httpStatus = statusCode; } - static resolveError(rawError: RawAPIError['errors'], props: string[] = []): string { + static resolveError(rawError: Exclude, props: string[] = []): string { const messages: string[] = []; for (const [key, value] of Object.entries(rawError)) { const int = parseInt(key); @@ -42,7 +42,7 @@ interface BaseError { export interface RawAPIError { code: number; message: string; - errors: { + errors?: { [key: string]: { _errors: BaseError[]; } | { diff --git a/src/rest/RequestManager.ts b/src/rest/RequestManager.ts index 49f8728..0ec96b2 100644 --- a/src/rest/RequestManager.ts +++ b/src/rest/RequestManager.ts @@ -4,6 +4,8 @@ import { Constants } from '../util'; import fetch, { Response } from 'node-fetch'; import APIError from './APIError'; import HTTPError from './HTTPError'; +import { RequestMethod } from '../util/constants'; +import { MethodRequestOptions } from './buildRoute'; const sleep = promisify(setTimeout); const parse = (response: Response) => { @@ -125,14 +127,10 @@ export interface RequestBucket { busy: boolean } -export interface RequestOptions { - data?: Record; - query?: Record | URLSearchParams; - reason?: string; + +export interface RequestOptions extends MethodRequestOptions { endpoint: string; bucket: string; resolve: (res: unknown) => void; reject: (error: Error) => void; -} - -export type RequestMethod = 'GET' | 'DELETE' | 'PATCH' | 'POST' \ No newline at end of file +} \ No newline at end of file diff --git a/src/rest/buildRoute.ts b/src/rest/buildRoute.ts new file mode 100644 index 0000000..21dd85b --- /dev/null +++ b/src/rest/buildRoute.ts @@ -0,0 +1,64 @@ +// inspired by discord.js https://github.com/discordjs/discord.js/blob/b0ab37ddc0614910e032ccf423816e106c3804e5/src/rest/APIRouter.js#L1 +import { Client } from '../client'; +import { RequestMethod } from '../util/constants'; +import { RequestOptions } from './RequestManager'; + +const ID_REGEX = /^\d{16,19}$/; +const MAJOR_PARAMETERS = ['channels', 'guilds']; + +const getBucket = (route: string[]) => { + const bucket = []; + for (let index = 0;index < route.length;index++) { + const endpoint = route[index]; + if (index === 0) { + bucket.push(endpoint); + continue; + } + const previous = route[index - 1]; + if (previous === 'reactions') break; + const isMajor = ID_REGEX.test(endpoint) && MAJOR_PARAMETERS.includes(previous); + bucket.push(isMajor ? ':id' : endpoint); + } + return bucket; +}; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +export const buildRoute = (client: Client): Route => { + const route: string[] = []; + const handler: ProxyHandler = { + apply(_, __, args: string[]) { + route.push(...args.filter(arg => typeof arg === 'string')); + return new Proxy(noop, handler); + }, + + get(_, endpoint) { + endpoint = endpoint.toString(); + const uppercaseEndpoint = endpoint.toUpperCase(); + if (uppercaseEndpoint in RequestMethod) { + return (options?: MethodRequestOptions) => new Promise( + (resolve, reject) => client.requestManager.makeRequest( + uppercaseEndpoint, Object.assign({}, options || {}, { + bucket: getBucket(route).join('/'), + endpoint: route.join('/'), resolve, reject + }) + ) + ); + } + route.push(endpoint); + return new Proxy(noop, handler); + } + }; + return new Proxy(noop, handler); +}; + +export interface MethodRequestOptions { + data?: Record; + query?: Record | URLSearchParams; + reason?: string; +} + +export type Route = Record Route)> & { + get(options?: MethodRequestOptions): Promise +} \ No newline at end of file diff --git a/src/util/constants.ts b/src/util/constants.ts index 43b3347..04a2503 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -175,4 +175,12 @@ export enum TeamMembershipState { export enum WebhookType { INCOMING = 1, CHANNEL_FOLLOWER = 2 +} + +export enum RequestMethod { + GET = 'GET', + DELETE = 'DELETE', + PATCH = 'PATCH', + PUT = 'PUT', + POST = 'POST' } \ No newline at end of file From 00cc6ff0e9e3a9879c4a31da6f1926eec2c26a9c Mon Sep 17 00:00:00 2001 From: NotSugden <28943913+NotSugden@users.noreply.github.com> Date: Fri, 4 Sep 2020 16:08:54 +0100 Subject: [PATCH 2/3] inital commit --- package.json | 6 +- src/client/index.ts | 19 +++- src/errors/index.ts | 33 +++++++ src/gateway/GatewayManager.ts | 170 ++++++++++++++++++++++++++++++++++ src/gateway/handlers/READY.ts | 7 ++ src/gateway/handlers/index.ts | 1 + src/gateway/types/Events.ts | 54 +++++++++++ src/gateway/types/Generics.ts | 0 src/gateway/types/Packets.ts | 99 ++++++++++++++++++++ src/gateway/types/Presence.ts | 19 ++++ src/util/constants.ts | 71 ++++++++++++-- src/util/index.ts | 6 +- 12 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 src/errors/index.ts create mode 100644 src/gateway/GatewayManager.ts create mode 100644 src/gateway/handlers/READY.ts create mode 100644 src/gateway/handlers/index.ts create mode 100644 src/gateway/types/Events.ts create mode 100644 src/gateway/types/Generics.ts create mode 100644 src/gateway/types/Packets.ts create mode 100644 src/gateway/types/Presence.ts diff --git a/package.json b/package.json index d67f6d5..c613457 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,14 @@ "dependencies": { "@types/node": "^14.6.2", "@types/node-fetch": "^2.5.7", + "@types/ws": "^7.2.6", "node-fetch": "^2.6.0", - "typescript": "^4.0.2" + "typescript": "^4.0.2", + "ws": "^7.3.1" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^3.10.1", "@typescript-eslint/parser": "^3.10.1", "eslint": "^7.7.0" } -} \ No newline at end of file +} diff --git a/src/client/index.ts b/src/client/index.ts index b1e0292..ce4c7ca 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -2,14 +2,19 @@ import { RequestManager } from '../rest'; import { Util, Constants } from '../util'; import { EventEmitter } from 'events'; import { buildRoute, Route } from '../rest/buildRoute'; +import { GatewayManager } from '../gateway/GatewayManager'; + +type Partialize = { + [K in keyof T]?: T[K] extends object ? Partialize : T[K]; +} export class Client extends EventEmitter { public requestManager: RequestManager; - + public gatewayManager: GatewayManager; public token!: string | null; public options: ClientOptions; - constructor(options: Partial = {}) { + constructor(options: Partialize = {}) { super(); this.options = Util.mergeDefaults(options, Constants.DEFAULT_CLIENT_OPTIONS); Object.defineProperty(this, @@ -19,16 +24,26 @@ export class Client extends EventEmitter { } ); this.requestManager = new RequestManager(this); + this.gatewayManager = new GatewayManager(this); } public get api(): Route { return buildRoute(this); } + + public async login(token = this.token): Promise { + this.token = token; + await this.gatewayManager.connect(); + return this; + } } export interface ClientOptions { rest: { requestOffset: number; timeout: number | null; + }, + gateway: { + largeThreshold: number; } } \ No newline at end of file diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..52c039f --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,33 @@ +type Params = ( + (typeof messages)[T] extends ((...args: unknown[]) => string) + ? Parameters<(typeof messages)[T]> + : never[] +); + +const makeError = (Class: typeof Error) => { + return class OrcusError< + T extends keyof typeof messages + > extends Class { + constructor( + message: T, + ...params: Params + ) { + const msg = ) => string)> messages[message]; + super(typeof msg === 'function' ? msg(...params) : msg); + this.name = Class.name; + } + }; +}; + +const _Error = makeError(Error); +const _TypeError = makeError(TypeError); + +export { + _Error as Error, + _TypeError as TypeError +}; + +const messages = { + MISSING_TOKEN: 'The Client does not have a token attached.', + INVALID_TYPE: (parameter: string, expected: string) => `Supplied ${parameter} is not ${expected}` +}; \ No newline at end of file diff --git a/src/gateway/GatewayManager.ts b/src/gateway/GatewayManager.ts new file mode 100644 index 0000000..21e292d --- /dev/null +++ b/src/gateway/GatewayManager.ts @@ -0,0 +1,170 @@ +import { Client } from '../client'; +import * as WebSocket from 'ws'; +import { Error } from '../errors'; +import { GATEWAY_VERSION, GatewayStatus, OPCode, WSEventType } from '../util/constants'; +import { TextDecoder } from 'util'; +import { RecievedPacket, OutgoingPacket } from './types/Packets'; +import { EventEmitter } from 'events'; +import * as EventHandlers from './handlers'; +import { WSEventPackets } from './types/Events'; + +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(data) + ); +}; +export class GatewayManager extends EventEmitter { + private _expectedGuilds: string[] | 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 { + const { token } = this.client; + if (!token) { + throw new Error('MISSING_TOKEN'); + } + const { url } = await this.client.api.gateway('bot').get<{ url: string }>(); + return new Promise((resolve, reject) => { + const onReady = () => { + this.client.emit('ready'); + resolve(); + this.off('disconnect', onClose); + }; + const onClose = () => { + if (this.status === GatewayStatus.RECONNECTING) return; + reject(); + this.off('ready', onReady); + this.off('disconnect', onClose); + }; + 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 = data => { + try { + 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 + this.status = GatewayStatus.DISCONNECTED; + this.emit('disconnect', code); + } + + public send(data: OutgoingPacket): Promise { + if (!this.websocket) { + this.client.emit('warn', 'GatewayManager#send was called before a connection was made.'); + return Promise.resolve(); + } + return new Promise( + (resolve, reject) => this.websocket!.send(JSON.stringify(data), error => { + if (error) reject(error); + else resolve(); + }) + ); + } + + private onMessage(data: WebSocket.Data) { + const packet: RecievedPacket = unpack(data); + this.client.emit('gatewayPacket', packet); + console.log(data); + if (packet.op === OPCode.DISPATCH) { + this.emit(packet.t, packet.d); + if (packet.t in EventHandlers) { + if (packet.t === WSEventType.READY) { + this._setupReady( packet.d); + } + EventHandlers[packet.t as keyof typeof EventHandlers]( + this, packet.d + ); + } + } else if (packet.op === OPCode.HEARTBEAT) { + this.heartbeatSequence = packet.d; + this.client.emit('debug', `Received heartbeat: ${packet.d} from the Gateway.`); + } else if (packet.op === OPCode.RECONNECT || packet.op === OPCode.INVALID_SESSION) { + // packet.d might be null, im not sure, so thats why `=== true` + if (!('d' in packet) || packet.d === true) { + this.status = GatewayStatus.RECONNECTING; + } + this.disconnect(); + } else if (packet.op === OPCode.HEARTBEAT_ACK) { + this.lastAck = new Date(); + } else if (packet.op === OPCode.HELLO) { + this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), packet.d.heartbeat_interval); + this.identify(); + } + } + + private sendHeartbeat() { + this.lastPing = new Date(); + this.send({ op: OPCode.HEARTBEAT, d: this.heartbeatSequence }); + } + + private identify() { + this.send({ + op: OPCode.IDENTIFY, d: { + properties: { + $browser: 'OrcusJS', + $device: 'OrcusJS', + $os: process.platform + }, + token: this.client.token!, + large_threshold: this.client.options.gateway.largeThreshold + } + }); + } + + // private method, except it has to be public to be used + private _setupReady(data: WSEventPackets['READY']) { + this._expectedGuilds = data.guilds.map(guild => guild.id); + this.sessionID = data.session_id; + } +} \ No newline at end of file diff --git a/src/gateway/handlers/READY.ts b/src/gateway/handlers/READY.ts new file mode 100644 index 0000000..6042ce2 --- /dev/null +++ b/src/gateway/handlers/READY.ts @@ -0,0 +1,7 @@ +import { WSEventPackets } from '../types/Events'; +import { GatewayManager } from '../GatewayManager'; + +export const READY = (manager: GatewayManager, data: WSEventPackets['READY']): null => { + manager.emit('ready', data); + return null; +}; \ No newline at end of file diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts new file mode 100644 index 0000000..f7d8614 --- /dev/null +++ b/src/gateway/handlers/index.ts @@ -0,0 +1 @@ +export * from './READY'; \ No newline at end of file diff --git a/src/gateway/types/Events.ts b/src/gateway/types/Events.ts new file mode 100644 index 0000000..5090bb0 --- /dev/null +++ b/src/gateway/types/Events.ts @@ -0,0 +1,54 @@ +import { APIUser } from '../../rest/types/User'; + +export interface WSEventPackets { + READY: { + v: number; + // user-accounts only, which isn't supported + user_settings: {}; + user: APIUser & { + verified: boolean; + mfa_enabled: boolean; + email: string | null; + }; + session_id: string; + relationships: []; + private_channels: []; + presences: []; + guilds: { unavailable: true; id: string }[]; + application: { id: string; flags: number }; + }; + CHANNEL_CREATE: Record; + CHANNEL_UPDATE: Record; + CHANNEL_DELETE: Record; + CHANNEL_PINS_UPDATE: Record; + GUILD_CREATE: Record; + GUILD_UPDATE: Record; + GUILD_DELETE: Record; + GUILD_BAN_ADD: Record; + GUILD_BAN_REMOVE: Record; + GUILD_EMOJIS_UPDATE: Record; + GUILD_INTEGRATIONS_UPDATE: Record; + GUILD_MEMBER_ADD: Record; + GUILD_MEMBER_REMOVE: Record; + GUILD_MEMBER_UPDATE: Record; + GUILD_MEMBERS_CHUNK: Record; + GUILD_ROLE_CREATE: Record; + GUILD_ROLE_UPDATE: Record; + GUILD_ROLE_DELETE: Record; + INVITE_CREATE: Record; + INVITE_DELETE: Record; + MESSAGE_CREATE: Record; + MESSAGE_UPDATE: Record; + MESSAGE_DELETE: Record; + MESSAGE_DELETE_BULK: Record; + MESSAGE_REACTION_ADD: Record; + MESSAGE_REACTION_REMOVE: Record; + MESSAGE_REACTION_REMOVE_ALL: Record; + MESSAGE_REACTION_REMOVE_EMOJI: Record; + PRESENCE_UPDATE: Record; + TYPING_START: Record; + USER_UPDATE: Record; + VOICE_STATE_UPDATE: Record; + VOICE_SERVER_UPDATE: Record; + WEBHOOKS_UPDATE: Record; +} \ No newline at end of file diff --git a/src/gateway/types/Generics.ts b/src/gateway/types/Generics.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/gateway/types/Packets.ts b/src/gateway/types/Packets.ts new file mode 100644 index 0000000..1f5434b --- /dev/null +++ b/src/gateway/types/Packets.ts @@ -0,0 +1,99 @@ +import { OPCode, WSEventType } from '../../util/constants'; +import { WSEventPackets } from './Events'; + +export interface EventPacket { + op: OPCode.DISPATCH; + d: WSEventPackets[T]; + t: T; + s: number; +} + +export interface HeartbeatPacketRecieve { + op: OPCode.HEARTBEAT; + d: number; +} + +export interface HeartbeatPacketSend { + op: OPCode.HEARTBEAT; + d: number | null; +} + +export interface IdentifyPacket { + op: OPCode.IDENTIFY, + d: { + token: string; + properties: { + $os: string; + $browser: string; // OrcusJS + $device: string; // OrcusJS + }; + large_threshold?: number; // 50-250 + shard?: [number, number]; // [shard id, number of shards] + } +} + +// 3 unknown atm + +export interface VoiceStateUpdatePacket { + op: OPCode.VOICE_STATE_UPDATE; + d: { + guild_id: string; + channel_id: string | null; + self_mute: boolean; + self_deaf: boolean; + } +} + +export interface ResumePacket { + op: OPCode.RESUME; + d: { + token: string; + session_id: string; + seq: number; + } +} + +export interface ReconnectPacket { + op: OPCode.RECONNECT; +} + +export interface RequestGuildMembersPacket { + op: OPCode.REQUEST_GUILD_MEMBERS; + d: ({ + guild_id: string; + limit: number; + presences?: boolean; + nonce?: string; + } & ({ query?: string } | { user_ids?: string | string[] })) +} + +export interface InvalidSessionPacket { + op: OPCode.INVALID_SESSION; + d: boolean; +} + +export interface HelloPacket { + op: OPCode.HELLO; + d: { + heartbeat_interval: number; + }; +} + +export interface HeartbeatACKPacket { + op: OPCode.HEARTBEAT_ACK; +} + +export type OutgoingPacket = + | HeartbeatPacketSend + | IdentifyPacket + | VoiceStateUpdatePacket + | ResumePacket + | RequestGuildMembersPacket; + +export type RecievedPacket = + | EventPacket + | HeartbeatPacketRecieve + | ReconnectPacket + | InvalidSessionPacket + | HelloPacket + | HeartbeatACKPacket; \ No newline at end of file diff --git a/src/gateway/types/Presence.ts b/src/gateway/types/Presence.ts new file mode 100644 index 0000000..6992190 --- /dev/null +++ b/src/gateway/types/Presence.ts @@ -0,0 +1,19 @@ +import { PresenceStatus, ActivityType } from '../../util/constants'; + +export interface PresenceUpdatePayload { + since: number | null; + game: null; + status: PresenceStatus; + afk: boolean; +} + +export interface ActivityPayloadBase { + name: string; + type: ActivityType; +} + +export interface StreamingActivity extends ActivityPayloadBase { + type: ActivityType.STREAMING; + url: string | null; + // unknown atm +} \ No newline at end of file diff --git a/src/util/constants.ts b/src/util/constants.ts index 04a2503..4ca5750 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,13 +1,25 @@ +import { ClientOptions } from '../client'; + export const API_URL = 'http://discord.com/api'; export const API_VERSION = 7; +export const GATEWAY_VERSION = 6; -export const DEFAULT_CLIENT_OPTIONS = { +export const DEFAULT_CLIENT_OPTIONS: ClientOptions = { rest: { requestOffset: 500, timeout: 20 * 1000 + }, + gateway: { + largeThreshold: 200 } }; +const mirror = (array: T[]) => { + const obj = > {}; + for (const key of array) obj[key] = key; + return obj; +}; + export enum AuditLogEvent { GUILD_UPDATE = 1, CHANNEL_CREATE = 10, @@ -177,10 +189,53 @@ export enum WebhookType { CHANNEL_FOLLOWER = 2 } -export enum RequestMethod { - GET = 'GET', - DELETE = 'DELETE', - PATCH = 'PATCH', - PUT = 'PUT', - POST = 'POST' -} \ No newline at end of file +export const RequestMethod = mirror(['GET', 'DELETE', 'PATCH', 'PUT', 'POST']); +export type RequestMethod = keyof typeof RequestMethod; + +export enum GatewayStatus { + NOT_CONNECTED = 0, + CONNECTING = 1, + DISCONNECTED = 2, + RECONNECTING = 3, + CONNECTED = 4 +} + +export enum OPCode { + DISPATCH = 0, + HEARTBEAT = 1, + IDENTIFY = 2, + PRESENCE_UPDATE = 3, + VOICE_STATE_UPDATE = 4, + RESUME = 6, + RECONNECT = 7, + REQUEST_GUILD_MEMBERS = 8, + INVALID_SESSION = 9, + HELLO = 10, + HEARTBEAT_ACK = 11 +} + +export enum PresenceStatus { + ONLINE = 'online', + DND = 'dnd', + IDLE = 'idle', + INVISIBLE = 'invisible', + OFFLINE = 'offlne' +} + +export enum ActivityType { + GAME = 0, + STREAMING = 1, + LISTENING = 2, + CUSTOM = 4 +} + +export const WSEventType = mirror([ + 'READY', 'CHANNEL_CREATE', 'CHANNEL_UPDATE', 'CHANNEL_DELETE', 'CHANNEL_PINS_UPDATE', + 'GUILD_CREATE', 'GUILD_UPDATE', 'GUILD_DELETE', 'GUILD_BAN_ADD', 'GUILD_BAN_REMOVE', + 'GUILD_EMOJIS_UPDATE', 'GUILD_INTEGRATIONS_UPDATE', 'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', + 'GUILD_MEMBER_UPDATE', 'GUILD_MEMBERS_CHUNK', 'GUILD_ROLE_CREATE', 'GUILD_ROLE_UPDATE', + 'GUILD_ROLE_DELETE', 'INVITE_CREATE', 'INVITE_DELETE', 'MESSAGE_CREATE', 'MESSAGE_UPDATE', + 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK', 'MESSAGE_REACTION_ADD', 'MESSAGE_REACTION_REMOVE', + 'MESSAGE_REACTION_REMOVE_ALL', 'MESSAGE_REACTION_REMOVE_EMOJI', 'PRESENCE_UPDATE', + 'TYPING_START', 'USER_UPDATE', 'VOICE_STATE_UPDATE', 'VOICE_SERVER_UPDATE', 'WEBHOOKS_UPDATE' +]); \ No newline at end of file diff --git a/src/util/index.ts b/src/util/index.ts index 25bc4cc..2e76dd8 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,7 +1,11 @@ export * as Constants from './constants'; +type Partialize = { + [K in keyof T]?: T[K] extends object ? Partialize : T[K]; +} + export class Util { - static mergeDefaults(toMerge: Partial, defaults: T): T { + static mergeDefaults(toMerge: Partialize, defaults: T): T { for (const [key, _default] of Object.entries(defaults)) { const typeofDefault = typeof _default; const typeofGiven = typeof toMerge[ key]; From e525aaca50e2f45cfaba254eb3a0a5cb40e0efa3 Mon Sep 17 00:00:00 2001 From: NotSugden <28943913+NotSugden@users.noreply.github.com> Date: Sat, 19 Sep 2020 05:50:24 +0100 Subject: [PATCH 3/3] WIP: gateway structure was also refactored im bad at commit messages --- .gitignore | 3 +- package.json | 12 +- src/client/Client.ts | 55 +++++ src/client/gateway/GatewayManager.ts | 241 ++++++++++++++++++++ src/client/gateway/handlers/GUILD_CREATE.ts | 7 + src/client/gateway/handlers/index.ts | 1 + src/client/gateway/index.ts | 3 + src/client/index.ts | 55 +---- src/{ => client}/rest/APIError.ts | 0 src/{ => client}/rest/HTTPError.ts | 0 src/{ => client}/rest/RequestManager.ts | 5 +- src/{ => client}/rest/buildRoute.ts | 6 +- src/{ => client}/rest/index.ts | 0 src/errors/index.ts | 10 +- src/gateway/GatewayManager.ts | 170 -------------- src/gateway/handlers/READY.ts | 7 - src/gateway/handlers/index.ts | 1 - src/gateway/types/Events.ts | 54 ----- src/gateway/types/Generics.ts | 0 src/gateway/types/Packets.ts | 99 -------- src/gateway/types/Presence.ts | 19 -- src/index.ts | 3 +- src/managers/BaseCacheManager.ts | 56 +++++ src/managers/UserCacheManager.ts | 28 +++ src/managers/index.ts | 2 + src/rest/types/Application.ts | 40 ---- src/rest/types/AuditLogEntry.ts | 36 --- src/rest/types/Channel.ts | 76 ------ src/rest/types/Embed.ts | 49 ---- src/rest/types/Emoji.ts | 19 -- src/rest/types/Generics.ts | 30 --- src/rest/types/Guild.ts | 83 ------- src/rest/types/GuildMember.ts | 11 - src/rest/types/Integration.ts | 31 --- src/rest/types/Invite.ts | 37 --- src/rest/types/Message.ts | 81 ------- src/rest/types/Overwrite.ts | 16 -- src/rest/types/Role.ts | 13 -- src/rest/types/User.ts | 10 - src/rest/types/Voice.ts | 8 - src/rest/types/Webhook.ts | 13 -- src/structures/Base.ts | 23 ++ src/structures/User.ts | 88 +++++++ src/structures/index.ts | 2 + src/util/ValueCache.ts | 59 +++++ src/util/constants.ts | 229 ++----------------- src/util/index.ts | 7 +- src/util/types.ts | 19 ++ 48 files changed, 635 insertions(+), 1182 deletions(-) create mode 100644 src/client/Client.ts create mode 100644 src/client/gateway/GatewayManager.ts create mode 100644 src/client/gateway/handlers/GUILD_CREATE.ts create mode 100644 src/client/gateway/handlers/index.ts create mode 100644 src/client/gateway/index.ts rename src/{ => client}/rest/APIError.ts (100%) rename src/{ => client}/rest/HTTPError.ts (100%) rename src/{ => client}/rest/RequestManager.ts (97%) rename src/{ => client}/rest/buildRoute.ts (93%) rename src/{ => client}/rest/index.ts (100%) delete mode 100644 src/gateway/GatewayManager.ts delete mode 100644 src/gateway/handlers/READY.ts delete mode 100644 src/gateway/handlers/index.ts delete mode 100644 src/gateway/types/Events.ts delete mode 100644 src/gateway/types/Generics.ts delete mode 100644 src/gateway/types/Packets.ts delete mode 100644 src/gateway/types/Presence.ts create mode 100644 src/managers/BaseCacheManager.ts create mode 100644 src/managers/UserCacheManager.ts create mode 100644 src/managers/index.ts delete mode 100644 src/rest/types/Application.ts delete mode 100644 src/rest/types/AuditLogEntry.ts delete mode 100644 src/rest/types/Channel.ts delete mode 100644 src/rest/types/Embed.ts delete mode 100644 src/rest/types/Emoji.ts delete mode 100644 src/rest/types/Generics.ts delete mode 100644 src/rest/types/Guild.ts delete mode 100644 src/rest/types/GuildMember.ts delete mode 100644 src/rest/types/Integration.ts delete mode 100644 src/rest/types/Invite.ts delete mode 100644 src/rest/types/Message.ts delete mode 100644 src/rest/types/Overwrite.ts delete mode 100644 src/rest/types/Role.ts delete mode 100644 src/rest/types/User.ts delete mode 100644 src/rest/types/Voice.ts delete mode 100644 src/rest/types/Webhook.ts create mode 100644 src/structures/Base.ts create mode 100644 src/structures/User.ts create mode 100644 src/structures/index.ts create mode 100644 src/util/ValueCache.ts create mode 100644 src/util/types.ts diff --git a/.gitignore b/.gitignore index 71f55de..f853be3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules/ # Build output build/ -package-lock.json \ No newline at end of file +package-lock.json +.vscode \ No newline at end of file diff --git a/package.json b/package.json index c613457..ba83b21 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,18 @@ }, "homepage": "https://github.com/NotSugden/OrcusJS#readme", "dependencies": { - "@types/node": "^14.6.2", - "@types/node-fetch": "^2.5.7", - "@types/ws": "^7.2.6", + "@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" } } diff --git a/src/client/Client.ts b/src/client/Client.ts new file mode 100644 index 0000000..3effbb6 --- /dev/null +++ b/src/client/Client.ts @@ -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 = { + [K in keyof T]?: T[K] extends object ? Partialize : 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 = {}) { + 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.token = token; + await this.gatewayManager.connect(); + return this; + } +} + +export interface ClientOptions { + rest: { + requestOffset: number; + timeout: number | null; + }, + gateway: { + largeThreshold: number; + } +} \ No newline at end of file diff --git a/src/client/gateway/GatewayManager.ts b/src/client/gateway/GatewayManager.ts new file mode 100644 index 0000000..b24c3e6 --- /dev/null +++ b/src/client/gateway/GatewayManager.ts @@ -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(data) + ); +}; + +export default class GatewayManager extends EventEmitter { + private _expectedGuilds: Set | 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 { + 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 { + if (!this.websocket) { + this.client.emit('warn', 'GatewayManager#send was called before a connection was made.'); + return Promise.resolve(); + } + return new Promise( + (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); + } +} diff --git a/src/client/gateway/handlers/GUILD_CREATE.ts b/src/client/gateway/handlers/GUILD_CREATE.ts new file mode 100644 index 0000000..21d3d4e --- /dev/null +++ b/src/client/gateway/handlers/GUILD_CREATE.ts @@ -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); +}; \ No newline at end of file diff --git a/src/client/gateway/handlers/index.ts b/src/client/gateway/handlers/index.ts new file mode 100644 index 0000000..83e21a8 --- /dev/null +++ b/src/client/gateway/handlers/index.ts @@ -0,0 +1 @@ +export { default as GUILD_CREATE } from './GUILD_CREATE'; \ No newline at end of file diff --git a/src/client/gateway/index.ts b/src/client/gateway/index.ts new file mode 100644 index 0000000..9293ae9 --- /dev/null +++ b/src/client/gateway/index.ts @@ -0,0 +1,3 @@ +export { + default as GatewayManager +} from './GatewayManager'; \ No newline at end of file diff --git a/src/client/index.ts b/src/client/index.ts index ce4c7ca..5fe4dd6 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,49 +1,6 @@ -import { RequestManager } from '../rest'; -import { Util, Constants } from '../util'; -import { EventEmitter } from 'events'; -import { buildRoute, Route } from '../rest/buildRoute'; -import { GatewayManager } from '../gateway/GatewayManager'; - -type Partialize = { - [K in keyof T]?: T[K] extends object ? Partialize : T[K]; -} - -export class Client extends EventEmitter { - public requestManager: RequestManager; - public gatewayManager: GatewayManager; - public token!: string | null; - public options: ClientOptions; - - constructor(options: Partialize = {}) { - 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); - this.gatewayManager = new GatewayManager(this); - } - - public get api(): Route { - return buildRoute(this); - } - - public async login(token = this.token): Promise { - this.token = token; - await this.gatewayManager.connect(); - return this; - } -} - -export interface ClientOptions { - rest: { - requestOffset: number; - timeout: number | null; - }, - gateway: { - largeThreshold: number; - } -} \ No newline at end of file +export { + default as Client, + ClientOptions +} from './Client'; +export * from './gateway'; +export * from './rest'; \ No newline at end of file diff --git a/src/rest/APIError.ts b/src/client/rest/APIError.ts similarity index 100% rename from src/rest/APIError.ts rename to src/client/rest/APIError.ts diff --git a/src/rest/HTTPError.ts b/src/client/rest/HTTPError.ts similarity index 100% rename from src/rest/HTTPError.ts rename to src/client/rest/HTTPError.ts diff --git a/src/rest/RequestManager.ts b/src/client/rest/RequestManager.ts similarity index 97% rename from src/rest/RequestManager.ts rename to src/client/rest/RequestManager.ts index 0ec96b2..7af6b54 100644 --- a/src/rest/RequestManager.ts +++ b/src/client/rest/RequestManager.ts @@ -1,10 +1,9 @@ -import { Client } from '../client'; +import Client from '../Client'; import { promisify } from 'util'; -import { Constants } from '../util'; import fetch, { Response } from 'node-fetch'; import APIError from './APIError'; import HTTPError from './HTTPError'; -import { RequestMethod } from '../util/constants'; +import { Constants, RequestMethod } from '../../util'; import { MethodRequestOptions } from './buildRoute'; const sleep = promisify(setTimeout); diff --git a/src/rest/buildRoute.ts b/src/client/rest/buildRoute.ts similarity index 93% rename from src/rest/buildRoute.ts rename to src/client/rest/buildRoute.ts index 21dd85b..8a41154 100644 --- a/src/rest/buildRoute.ts +++ b/src/client/rest/buildRoute.ts @@ -1,6 +1,6 @@ // inspired by discord.js https://github.com/discordjs/discord.js/blob/b0ab37ddc0614910e032ccf423816e106c3804e5/src/rest/APIRouter.js#L1 -import { Client } from '../client'; -import { RequestMethod } from '../util/constants'; +import Client from '../Client'; +import { Constants, RequestMethod } from '../../util'; import { RequestOptions } from './RequestManager'; const ID_REGEX = /^\d{16,19}$/; @@ -36,7 +36,7 @@ export const buildRoute = (client: Client): Route => { get(_, endpoint) { endpoint = endpoint.toString(); const uppercaseEndpoint = endpoint.toUpperCase(); - if (uppercaseEndpoint in RequestMethod) { + if (uppercaseEndpoint in Constants.RequestMethods) { return (options?: MethodRequestOptions) => new Promise( (resolve, reject) => client.requestManager.makeRequest( uppercaseEndpoint, Object.assign({}, options || {}, { diff --git a/src/rest/index.ts b/src/client/rest/index.ts similarity index 100% rename from src/rest/index.ts rename to src/client/rest/index.ts diff --git a/src/errors/index.ts b/src/errors/index.ts index 52c039f..371ec8f 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -8,13 +8,18 @@ const makeError = (Class: typeof Error) => { return class OrcusError< T extends keyof typeof messages > extends Class { + public code: string; constructor( message: T, ...params: Params ) { const msg = ) => string)> messages[message]; super(typeof msg === 'function' ? msg(...params) : msg); - this.name = Class.name; + this.code = message; + } + + get name() { + return `${super.name} [${this.code}]`; } }; }; @@ -29,5 +34,6 @@ export { const messages = { MISSING_TOKEN: 'The Client does not have a token attached.', - INVALID_TYPE: (parameter: string, expected: string) => `Supplied ${parameter} is not ${expected}` + INVALID_TYPE: (parameter: string, expected: string) => `Supplied ${parameter} is not ${expected}`, + INVALID_TOKEN: 'An invalid token was provided' }; \ No newline at end of file diff --git a/src/gateway/GatewayManager.ts b/src/gateway/GatewayManager.ts deleted file mode 100644 index 21e292d..0000000 --- a/src/gateway/GatewayManager.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Client } from '../client'; -import * as WebSocket from 'ws'; -import { Error } from '../errors'; -import { GATEWAY_VERSION, GatewayStatus, OPCode, WSEventType } from '../util/constants'; -import { TextDecoder } from 'util'; -import { RecievedPacket, OutgoingPacket } from './types/Packets'; -import { EventEmitter } from 'events'; -import * as EventHandlers from './handlers'; -import { WSEventPackets } from './types/Events'; - -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(data) - ); -}; -export class GatewayManager extends EventEmitter { - private _expectedGuilds: string[] | 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 { - const { token } = this.client; - if (!token) { - throw new Error('MISSING_TOKEN'); - } - const { url } = await this.client.api.gateway('bot').get<{ url: string }>(); - return new Promise((resolve, reject) => { - const onReady = () => { - this.client.emit('ready'); - resolve(); - this.off('disconnect', onClose); - }; - const onClose = () => { - if (this.status === GatewayStatus.RECONNECTING) return; - reject(); - this.off('ready', onReady); - this.off('disconnect', onClose); - }; - 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 = data => { - try { - 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 - this.status = GatewayStatus.DISCONNECTED; - this.emit('disconnect', code); - } - - public send(data: OutgoingPacket): Promise { - if (!this.websocket) { - this.client.emit('warn', 'GatewayManager#send was called before a connection was made.'); - return Promise.resolve(); - } - return new Promise( - (resolve, reject) => this.websocket!.send(JSON.stringify(data), error => { - if (error) reject(error); - else resolve(); - }) - ); - } - - private onMessage(data: WebSocket.Data) { - const packet: RecievedPacket = unpack(data); - this.client.emit('gatewayPacket', packet); - console.log(data); - if (packet.op === OPCode.DISPATCH) { - this.emit(packet.t, packet.d); - if (packet.t in EventHandlers) { - if (packet.t === WSEventType.READY) { - this._setupReady( packet.d); - } - EventHandlers[packet.t as keyof typeof EventHandlers]( - this, packet.d - ); - } - } else if (packet.op === OPCode.HEARTBEAT) { - this.heartbeatSequence = packet.d; - this.client.emit('debug', `Received heartbeat: ${packet.d} from the Gateway.`); - } else if (packet.op === OPCode.RECONNECT || packet.op === OPCode.INVALID_SESSION) { - // packet.d might be null, im not sure, so thats why `=== true` - if (!('d' in packet) || packet.d === true) { - this.status = GatewayStatus.RECONNECTING; - } - this.disconnect(); - } else if (packet.op === OPCode.HEARTBEAT_ACK) { - this.lastAck = new Date(); - } else if (packet.op === OPCode.HELLO) { - this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), packet.d.heartbeat_interval); - this.identify(); - } - } - - private sendHeartbeat() { - this.lastPing = new Date(); - this.send({ op: OPCode.HEARTBEAT, d: this.heartbeatSequence }); - } - - private identify() { - this.send({ - op: OPCode.IDENTIFY, d: { - properties: { - $browser: 'OrcusJS', - $device: 'OrcusJS', - $os: process.platform - }, - token: this.client.token!, - large_threshold: this.client.options.gateway.largeThreshold - } - }); - } - - // private method, except it has to be public to be used - private _setupReady(data: WSEventPackets['READY']) { - this._expectedGuilds = data.guilds.map(guild => guild.id); - this.sessionID = data.session_id; - } -} \ No newline at end of file diff --git a/src/gateway/handlers/READY.ts b/src/gateway/handlers/READY.ts deleted file mode 100644 index 6042ce2..0000000 --- a/src/gateway/handlers/READY.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { WSEventPackets } from '../types/Events'; -import { GatewayManager } from '../GatewayManager'; - -export const READY = (manager: GatewayManager, data: WSEventPackets['READY']): null => { - manager.emit('ready', data); - return null; -}; \ No newline at end of file diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts deleted file mode 100644 index f7d8614..0000000 --- a/src/gateway/handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './READY'; \ No newline at end of file diff --git a/src/gateway/types/Events.ts b/src/gateway/types/Events.ts deleted file mode 100644 index 5090bb0..0000000 --- a/src/gateway/types/Events.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { APIUser } from '../../rest/types/User'; - -export interface WSEventPackets { - READY: { - v: number; - // user-accounts only, which isn't supported - user_settings: {}; - user: APIUser & { - verified: boolean; - mfa_enabled: boolean; - email: string | null; - }; - session_id: string; - relationships: []; - private_channels: []; - presences: []; - guilds: { unavailable: true; id: string }[]; - application: { id: string; flags: number }; - }; - CHANNEL_CREATE: Record; - CHANNEL_UPDATE: Record; - CHANNEL_DELETE: Record; - CHANNEL_PINS_UPDATE: Record; - GUILD_CREATE: Record; - GUILD_UPDATE: Record; - GUILD_DELETE: Record; - GUILD_BAN_ADD: Record; - GUILD_BAN_REMOVE: Record; - GUILD_EMOJIS_UPDATE: Record; - GUILD_INTEGRATIONS_UPDATE: Record; - GUILD_MEMBER_ADD: Record; - GUILD_MEMBER_REMOVE: Record; - GUILD_MEMBER_UPDATE: Record; - GUILD_MEMBERS_CHUNK: Record; - GUILD_ROLE_CREATE: Record; - GUILD_ROLE_UPDATE: Record; - GUILD_ROLE_DELETE: Record; - INVITE_CREATE: Record; - INVITE_DELETE: Record; - MESSAGE_CREATE: Record; - MESSAGE_UPDATE: Record; - MESSAGE_DELETE: Record; - MESSAGE_DELETE_BULK: Record; - MESSAGE_REACTION_ADD: Record; - MESSAGE_REACTION_REMOVE: Record; - MESSAGE_REACTION_REMOVE_ALL: Record; - MESSAGE_REACTION_REMOVE_EMOJI: Record; - PRESENCE_UPDATE: Record; - TYPING_START: Record; - USER_UPDATE: Record; - VOICE_STATE_UPDATE: Record; - VOICE_SERVER_UPDATE: Record; - WEBHOOKS_UPDATE: Record; -} \ No newline at end of file diff --git a/src/gateway/types/Generics.ts b/src/gateway/types/Generics.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/gateway/types/Packets.ts b/src/gateway/types/Packets.ts deleted file mode 100644 index 1f5434b..0000000 --- a/src/gateway/types/Packets.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { OPCode, WSEventType } from '../../util/constants'; -import { WSEventPackets } from './Events'; - -export interface EventPacket { - op: OPCode.DISPATCH; - d: WSEventPackets[T]; - t: T; - s: number; -} - -export interface HeartbeatPacketRecieve { - op: OPCode.HEARTBEAT; - d: number; -} - -export interface HeartbeatPacketSend { - op: OPCode.HEARTBEAT; - d: number | null; -} - -export interface IdentifyPacket { - op: OPCode.IDENTIFY, - d: { - token: string; - properties: { - $os: string; - $browser: string; // OrcusJS - $device: string; // OrcusJS - }; - large_threshold?: number; // 50-250 - shard?: [number, number]; // [shard id, number of shards] - } -} - -// 3 unknown atm - -export interface VoiceStateUpdatePacket { - op: OPCode.VOICE_STATE_UPDATE; - d: { - guild_id: string; - channel_id: string | null; - self_mute: boolean; - self_deaf: boolean; - } -} - -export interface ResumePacket { - op: OPCode.RESUME; - d: { - token: string; - session_id: string; - seq: number; - } -} - -export interface ReconnectPacket { - op: OPCode.RECONNECT; -} - -export interface RequestGuildMembersPacket { - op: OPCode.REQUEST_GUILD_MEMBERS; - d: ({ - guild_id: string; - limit: number; - presences?: boolean; - nonce?: string; - } & ({ query?: string } | { user_ids?: string | string[] })) -} - -export interface InvalidSessionPacket { - op: OPCode.INVALID_SESSION; - d: boolean; -} - -export interface HelloPacket { - op: OPCode.HELLO; - d: { - heartbeat_interval: number; - }; -} - -export interface HeartbeatACKPacket { - op: OPCode.HEARTBEAT_ACK; -} - -export type OutgoingPacket = - | HeartbeatPacketSend - | IdentifyPacket - | VoiceStateUpdatePacket - | ResumePacket - | RequestGuildMembersPacket; - -export type RecievedPacket = - | EventPacket - | HeartbeatPacketRecieve - | ReconnectPacket - | InvalidSessionPacket - | HelloPacket - | HeartbeatACKPacket; \ No newline at end of file diff --git a/src/gateway/types/Presence.ts b/src/gateway/types/Presence.ts deleted file mode 100644 index 6992190..0000000 --- a/src/gateway/types/Presence.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PresenceStatus, ActivityType } from '../../util/constants'; - -export interface PresenceUpdatePayload { - since: number | null; - game: null; - status: PresenceStatus; - afk: boolean; -} - -export interface ActivityPayloadBase { - name: string; - type: ActivityType; -} - -export interface StreamingActivity extends ActivityPayloadBase { - type: ActivityType.STREAMING; - url: string | null; - // unknown atm -} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1ea90fe..54a2da4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ export { version } from '../package.json'; export * from './client'; -export * from './util'; -export * from './rest'; \ No newline at end of file +export * from './util'; \ No newline at end of file diff --git a/src/managers/BaseCacheManager.ts b/src/managers/BaseCacheManager.ts new file mode 100644 index 0000000..5cb67a0 --- /dev/null +++ b/src/managers/BaseCacheManager.ts @@ -0,0 +1,56 @@ +// Slightly inspired by Discord.JS's Manager system +// https://github.com/discordjs/discord.js/blob/1e63f3756e814d6b1a2a9c17af9c2b28ce37e472/src/managers/BaseManager.js + +import { FetchOptions } from '../util'; +import { Client } from '../client'; +import Base from '../structures/Base'; +import ValueCache from '../util/ValueCache'; + +type Constructable = new (client: Client, data: D) => T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ClassType = T extends new (...args: any[]) => infer R ? R : never + +export default abstract class BaseCacheManager< + Raw extends { id: string }, + Holds extends Constructable, Raw> +> { + protected readonly cacheType!: Holds; + protected readonly client!: Client; + + public cache: ValueCache>; + + protected constructor( + client: Client, cacheType: Holds + ) { + Object.defineProperties(this, { + cacheType: { value: cacheType }, + client: { value: client } + }); + this.cache = new ValueCache>(); + } + + /** + * @internal This method is for internal use only + */ + public add(data: Raw): ClassType { + const existing = this.cache.get(data.id); + if (existing) { + existing._update(data); + return existing; + } + const struct = > new this.cacheType(this.client, data); + this.cache.set(struct.id, struct); + return struct; + } + + /** + * @internal This method is for internal use only + */ + public remove(structOrId: ClassType | string): void { + this.cache.delete(typeof structOrId === 'string' ? structOrId : structOrId.id); + } + + public abstract fetch(struct: ClassType, options?: FetchOptions): Promise>; + public abstract fetch(id: string, options?: FetchOptions): Promise>; +} + diff --git a/src/managers/UserCacheManager.ts b/src/managers/UserCacheManager.ts new file mode 100644 index 0000000..50a5ee6 --- /dev/null +++ b/src/managers/UserCacheManager.ts @@ -0,0 +1,28 @@ +import { APIUser } from 'discord-api-types/v6'; +import { Client } from '../client'; +import { User } from '../structures'; +import { FetchOptions } from '../util'; +import BaseCacheManager from './BaseCacheManager'; + +export default class UserCacheManager extends BaseCacheManager { + constructor(client: Client) { + super(client, User); + } + + async fetch(userOrID: User | string, options: FetchOptions = {}): Promise { + let existing: User | null = null; + if (typeof userOrID === 'string') { + existing = this.cache.get(userOrID) || null; + if (existing && !options.force) return existing; + } else { + existing = userOrID; + userOrID = userOrID.id; + } + const data = await this.client.api.users(userOrID).get(); + if (existing && options.cache === false) { + existing._update(data); + return existing; + } + return this.add(data); + } +} \ No newline at end of file diff --git a/src/managers/index.ts b/src/managers/index.ts new file mode 100644 index 0000000..3c2164d --- /dev/null +++ b/src/managers/index.ts @@ -0,0 +1,2 @@ +export { default as BaseCacheManager } from './BaseCacheManager'; +export { default as UserCacheManager } from './UserCacheManager'; \ No newline at end of file diff --git a/src/rest/types/Application.ts b/src/rest/types/Application.ts deleted file mode 100644 index 15c7417..0000000 --- a/src/rest/types/Application.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Base } from './Generics'; -import { APIUser } from './User'; -import { TeamMembershipState } from '../../util/constants'; - -interface BaseApplication extends Base { - name: string; - icon: string | null; - description: string; - summary: string; -} - -export interface APIClientApplication extends BaseApplication { - rpc_origins?: string[]; - bot_public: boolean; - bot_require_code_grant: boolean; - owner: APIUser; - verify_key: string; - team: APITeam | null; - guild_id?: string; - primary_sku_id?: string; - slug?: string; - cover_image?: string; -} - -export interface APIIntegrationApplication extends BaseApplication { - bot?: APIUser; -} - -export interface APITeam extends Base { - icon: string | null; - owner_user_id: string; - members: APITeamMember[]; -} - -export interface APITeamMember { - permissions: ['*']; - team_id: string; - user: APIUser; - membership_state: TeamMembershipState; -} \ No newline at end of file diff --git a/src/rest/types/AuditLogEntry.ts b/src/rest/types/AuditLogEntry.ts deleted file mode 100644 index 3d841b1..0000000 --- a/src/rest/types/AuditLogEntry.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { APIOverwrite } from './Overwrite'; -import { AuditLogEvent } from '../../util/constants'; -import { PartialObject, Base } from './Generics'; - -export interface APIAuditLogEntry extends Base { - target_id: string; - changes?: APIAuditLogChange[]; - user_id: string; - action_type: AuditLogEvent; - options?: AuditLogEntryInfo; - reason?: string; -} - -export interface APIAuditLogChange { - new_value?: AuditLogChangeValue; - old_value?: AuditLogChangeValue; - key: string; -} - -export interface AuditLogEntryInfo { - delete_member_days?: string; - members_removed?: string; - channel_id?: string; - message_id?: string; - count?: string; - type?: string; - id?: string; - role_name?: string; -} - -export type AuditLogChangeValue = - | string - | number // Includes ChannelType - | boolean - | PartialObject[] // Role - | APIOverwrite[]; \ No newline at end of file diff --git a/src/rest/types/Channel.ts b/src/rest/types/Channel.ts deleted file mode 100644 index 42babe4..0000000 --- a/src/rest/types/Channel.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ChannelType } from '../../util/constants'; -import { APIOverwrite } from './Overwrite'; -import { APIUser } from './User'; -import { Base } from './Generics'; - -interface BaseChannel extends Base { - name: string; - type: ChannelType; -} - -interface TextBasedChannel extends BaseChannel { - last_message_id: string | null; - last_pin_timestamp: string; -} - -export interface APIGuildChannelBase extends BaseChannel { - guild_id: string; - position: number; - permission_overwrites: APIOverwrite[]; - parent_id: string | null; - nsfw: boolean; -} - -export interface APITextChannel extends APIGuildChannelBase, TextBasedChannel { - type: ChannelType.TEXT, - topic: string | null; - rate_limit_per_user: number; -} - -export interface APIDMChannel extends Omit { - type: ChannelType.DM, - recipients: [APIUser], -} - -export interface APIVoiceChannel extends APIGuildChannelBase { - type: ChannelType.VOICE; - nsfw: false; - bitrate: number; - user_limit: number; -} - -export interface APIGroupDMChannel extends Omit { - type: ChannelType.GROUP_DM; - name: null | string; - icon: null | string; -} - -export interface APICategoryChannel extends APIGuildChannelBase { - type: ChannelType.CATEGORY; - parent_id: null; - nsfw: false; -} - -export interface APINewsChannel extends Omit { - type: ChannelType.NEWS; -} - -export interface APIStoreChannel extends APIGuildChannelBase { - type: ChannelType.STORE; - nsfw: false; -} - -export interface APIChannelMention extends BaseChannel { - guild_id: string; -} - -// Group DM channel can only come from the `invites/{invite.code}` endpoint -export type APIChannel = - | APITextChannel - | APIDMChannel - | APIVoiceChannel - | APICategoryChannel - | APINewsChannel - | APICategoryChannel; - -export type APIGuildChannel = Exclude; \ No newline at end of file diff --git a/src/rest/types/Embed.ts b/src/rest/types/Embed.ts deleted file mode 100644 index bc4a3e6..0000000 --- a/src/rest/types/Embed.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { BaseAttachment } from './Message'; - -export interface APIEmbed { - title?: string; - type: EmbedType; - description?: string; - url?: string; - timestamp?: string; - color?: number; - footer?: APIEmbedFooter; - image?: APIEmbedImage; - thumbnail?: APIEmbedThumbnail; - video?: APIEmbedVideo; - provider?: APIEmbedProvider; - author?: APIEmbedAuthor; - fields?: APIEmbedField[]; -} - -export interface APIEmbedFooter { - text: string; - icon_url?: string; - proxy_icon_url?: string; -} - -export interface APIEmbedProvider { - name: string; - url: string; -} - -export interface APIEmbedAuthor { - name: string; - url?: string; - icon_url?: string; - proxy_icon_url?: string; -} - -export interface APIEmbedField { - name: string; - value: string; - inline: boolean; -} - -export type APIEmbedImage = BaseAttachment; - -export type APIEmbedThumbnail = BaseAttachment; - -export type APIEmbedVideo = Omit; - -export type EmbedType = 'rich' | 'image' | 'video' | 'gifv' | 'article' | 'link' \ No newline at end of file diff --git a/src/rest/types/Emoji.ts b/src/rest/types/Emoji.ts deleted file mode 100644 index b8b6d1f..0000000 --- a/src/rest/types/Emoji.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { APIUser } from './User'; -import { Base } from './Generics'; - -export interface APIUnicodeEmoji { - id: null; - name: string; -} - -export interface APIGuildEmoji extends Base { - name: string; - roles: string[]; - user: APIUser; - require_colons: boolean; - managed: boolean; - animated: boolean; - available: boolean; -} - -export type APIEmoji = APIUnicodeEmoji | APIGuildEmoji; \ No newline at end of file diff --git a/src/rest/types/Generics.ts b/src/rest/types/Generics.ts deleted file mode 100644 index dad5699..0000000 --- a/src/rest/types/Generics.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { APIUser } from './User'; -import { ChannelType } from '../../util/constants'; - -export interface Base { - id: string; -} - -export interface PartialObject extends Base { - name: string; -} - -export interface APIBan { - user: APIUser; - reason: string | null; -} - -export interface PartialAPIChannel extends Base { - name: string; - type: T; -} - -export interface APIVanityURL { - code: string | null; - uses: number; -} - -export interface APIGuildWidget { - enabled: boolean; - channel_id: string | null; -} \ No newline at end of file diff --git a/src/rest/types/Guild.ts b/src/rest/types/Guild.ts deleted file mode 100644 index a0b530f..0000000 --- a/src/rest/types/Guild.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Base } from './Generics'; -import { VerificationLevel, DefaultMessageNotification, ExplicitContentFilter, MFALevel, PremiumTier } from '../../util/constants'; -import { APIRole } from './Role'; -import { APIGuildEmoji } from './Emoji'; - -export interface APIGuildPreview extends Base { - name: string; - icon: string | null; - splash: string | null; - discovery_splash: string | null; - emojis: APIGuildEmoji[]; - features: GuildFeature[]; - description: string | null; - approximate_member_count: number; - approximate_presence_count: number; -} - -export interface APIGuild extends Omit< - APIGuildPreview, 'approximate_member_count' | 'approximate_presence_count' -> { - // true if the logged in user is the owner - owner?: true; - // permissions(_new) and owner only available through `users/@me/guilds` endpoint - permissions?: number; - permissions_new?: string; - owner_id: string; - region: string; - afk_channel_id: string | null; - afk_timeout: number; - // embed_* deprecated in favour of widget_* - embed_enabled: boolean; - embed_channel_id: string | null; - widget_enabled: boolean; - widget_channel_id: string | null; - verification_level: VerificationLevel; - default_message_notifications: DefaultMessageNotification; - explicit_content_filter: ExplicitContentFilter; - roles: APIRole[]; - mfa_level: MFALevel; - application_id: string | null; - system_channel_id: string | null; - system_channel_flags: number; - max_presences: number | null; - max_members: number; - vanity_url_code: string | null; - banner: string | null; - premium_tier: PremiumTier; - premium_subscription_count: number; - preferred_locale: string; - public_updates_channel_id: string | null; - max_video_channel_users: number; - // only if the `with_counts` parameter is true in `guilds/{guild.id}` endpoint - approximate_member_count?: number; - approximate_presence_count?: number; -} - -export type GuildFeature = - | 'INVITE_SPLASH' - | 'VIP_REGIONS' - | 'VANITY_URL' - | 'VERIFIED' - | 'PARTNERED' - | 'PUBLIC' - | 'COMMERCE' - | 'NEWS' - | 'DISCOVERABLE' - | 'FEATURABLE' - | 'ANIMATED_ICON' - | 'BANNER' - | 'PUBLIC_DISABLED' - | 'WELCOME_SCREEN_ENABLED'; - -export interface PartialAPIGuild { - id: string; - name: string; - splash: string | null; - banner: string | null; - description: string; - icon: string | null; - features: GuildFeature[]; - verification_level: VerificationLevel; - vanity_url_code: string | null; -} \ No newline at end of file diff --git a/src/rest/types/GuildMember.ts b/src/rest/types/GuildMember.ts deleted file mode 100644 index 94aba5d..0000000 --- a/src/rest/types/GuildMember.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { APIUser } from './User'; - -export interface APIGuildMember { - user: APIUser; - nick: string | null; - roles: string[]; - joined_at: string; - premium_since: string; - deaf: boolean; - mute: boolean; -} \ No newline at end of file diff --git a/src/rest/types/Integration.ts b/src/rest/types/Integration.ts deleted file mode 100644 index b2a58ab..0000000 --- a/src/rest/types/Integration.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Base } from './Generics'; -import { IntegrationType, IntegrationExpireBehavior } from '../../util/constants'; -import { APIUser } from './User'; -import { APIIntegrationApplication } from './Application'; - -interface BaseIntegration extends Base { - name: string; - type: IntegrationType; - enabled: boolean; - syncing: boolean; - role_id: string; - expire_behavior: IntegrationExpireBehavior; - expire_grace_period: number; - user?: APIUser; - account: APIIntegrationAccount; - synced_at: string; - subscriber_count: number; - revoked: boolean; -} - -export interface APIIntegrationAccount extends Base { - name: string; -} - -export interface APITwitchIntegration extends BaseIntegration { - enable_emoticons: boolean; -} - -export interface BotIntegration extends BaseIntegration { - application: APIIntegrationApplication -} \ No newline at end of file diff --git a/src/rest/types/Invite.ts b/src/rest/types/Invite.ts deleted file mode 100644 index 8bf34f1..0000000 --- a/src/rest/types/Invite.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { APIGroupDMChannel } from './Channel'; -import { PartialAPIChannel } from './Generics'; -import { ChannelType } from '../../util/constants'; -import { APIUser } from './User'; -import { PartialAPIGuild } from './Guild'; - -interface BaseInvite { - code: string; - channel: APIGroupDMChannel | PartialAPIChannel; -} - -export interface APIGroupDMInvite extends BaseInvite { - channel: APIGroupDMChannel; - inviter: APIUser; -} - -export interface APIGuildInvite extends BaseInvite { - guild: PartialAPIGuild; - channel: PartialAPIChannel; - inviter: APIUser; - // only present when `with_counts` is true on `/invites/{invite.code}` - approximate_member_count?: number; - approximate_presence_count?: number; -} - -export type APIVanityInvite = Omit - -export type APIInvite = APIVanityInvite | APIGuildInvite | APIGroupDMInvite; - -export interface APIInviteMeta extends APIVanityInvite { - inviter?: APIUser; - uses: number; - max_uses: number; - max_age: number; - temporary: boolean; - created_at: string; -} \ No newline at end of file diff --git a/src/rest/types/Message.ts b/src/rest/types/Message.ts deleted file mode 100644 index 1cc859f..0000000 --- a/src/rest/types/Message.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { APIUser } from './User'; -import { APIEmbed } from './Embed'; -import { APIEmoji } from './Emoji'; -import { MessageActivityType, MessageType } from '../../util/constants'; -import { APIChannelMention } from './Channel'; -import { Base } from './Generics'; - -interface BaseMessage extends Base { - channel_id: string; - author: APIUser; - content: string; - timestamp: string; - edited_timestamp: string | null; - tts: boolean; - mention_everyone: boolean; - mentions: APIUser[]; - mention_roles: string[]; - attachments: APIAttachment[]; - embeds: APIEmbed[]; - reactions?: APIMessageReaction[]; - pinned: boolean; - activity?: APIMessageActivity; - application?: APIMessageApplication; - flags?: number; -} - -export interface APIDMMessage extends BaseMessage { - type: MessageType.DEFAULT | MessageType.CHANNEL_PINNED_MESSAGE; - mention_everyone: false; - mention_roles: never[]; -} - -export interface APIGuildMessage extends BaseMessage { - type: MessageType; - guild_id: string; -} - -export interface APIWebhookMessage extends APIGuildMessage { - type: MessageType.DEFAULT; - webhook_id: string; -} - -export interface APICrosspostedMessage extends APIWebhookMessage { - mention_channels?: APIChannelMention[]; -} - -export type APIMessage = APICrosspostedMessage | APIWebhookMessage | APIGuildMessage | APIDMMessage; - -export interface BaseAttachment { - url: string; - proxy_url: string; - height: number; - width: number; -} - -export interface APIAttachment extends Omit { - id: string; - filename: string; - size: number; - height: number | null; - width: number | null; -} - -export interface APIMessageReaction { - count: number; - me: boolean; - emoji: APIEmoji; -} - -export interface APIMessageActivity { - type: MessageActivityType; - party_id?: string; -} - -export interface APIMessageApplication { - id: string; - cover_image?: string; - description: string; - icon: string | null; - name: string; -} \ No newline at end of file diff --git a/src/rest/types/Overwrite.ts b/src/rest/types/Overwrite.ts deleted file mode 100644 index 9b9d909..0000000 --- a/src/rest/types/Overwrite.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Base } from './Generics'; - -export interface APIOverwrite extends Base { - type: 'role' | 'member'; - // legacy - allow: number; - deny: number; - allow_new: string; - deny_new: string; -} - -export interface OverwriteData extends Base { - type: APIOverwrite['type']; - allow: string | number; - deny: string | number; -} \ No newline at end of file diff --git a/src/rest/types/Role.ts b/src/rest/types/Role.ts deleted file mode 100644 index 01b431f..0000000 --- a/src/rest/types/Role.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Base } from './Generics'; - -export interface APIRole extends Base { - name: string; - color: number; - hoist: boolean; - position: number; - // legacy - permissions: number; - permissions_new: string; - managed: boolean; - mentionable: boolean; -} \ No newline at end of file diff --git a/src/rest/types/User.ts b/src/rest/types/User.ts deleted file mode 100644 index 90025a9..0000000 --- a/src/rest/types/User.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Base } from './Generics'; - -export interface APIUser extends Base { - username: string; - avatar: string | null; - discriminator: string; - public_flags: number; - // bot is only present if true - bot?: true; -} \ No newline at end of file diff --git a/src/rest/types/Voice.ts b/src/rest/types/Voice.ts deleted file mode 100644 index 457c886..0000000 --- a/src/rest/types/Voice.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface APIVoiceRegion { - id: string; - name: string; - vip: boolean; - custom: boolean; - deprecated: boolean; - optimal: boolean; -} \ No newline at end of file diff --git a/src/rest/types/Webhook.ts b/src/rest/types/Webhook.ts deleted file mode 100644 index 5b881fe..0000000 --- a/src/rest/types/Webhook.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Base } from './Generics'; -import { WebhookType } from '../../util/constants'; -import { APIUser } from './User'; - -export interface APIWebhook extends Base { - type: WebhookType; - guild_id?: string; - channel_id: string; - user?: APIUser; - name: string | null; - avatar: string | null; - token?: string; -} \ No newline at end of file diff --git a/src/structures/Base.ts b/src/structures/Base.ts new file mode 100644 index 0000000..5ada303 --- /dev/null +++ b/src/structures/Base.ts @@ -0,0 +1,23 @@ +import { Client } from '../client'; + +export default abstract class Base { + protected readonly client!: Client; + + public readonly id!: string; + + constructor(client: Client, data: Raw) { + Object.defineProperties(this, { + client: { value: client }, + id: { enumerable: true, value: data.id, writable: false } + }); + + this._update(data); + } + + public abstract equals(data: Raw | Base): boolean; + + /** + * @internal This method is for internal use only + */ + public abstract _update(data: Raw): void +} \ No newline at end of file diff --git a/src/structures/User.ts b/src/structures/User.ts new file mode 100644 index 0000000..9aa4e63 --- /dev/null +++ b/src/structures/User.ts @@ -0,0 +1,88 @@ +import { APIUser } from 'discord-api-types/v6'; +import Util, { Constants, UserAvatarURLOptions } from '../util'; +import Base from './Base'; + +export default class User extends Base { + public avatarHash!: string | null; + public bot!: boolean; + public discriminator!: string; + // TODO: make class to handle this + public publicFlags!: number; + public system!: boolean; + public username!: string; + + public get defaultAvatarURL(): string { + return `${Constants.CDN_URL}/embed/avatars/${Number(this.discriminator) % 5}.png`; + } + + public avatarURL(options?: UserAvatarURLOptions & { fallbackDefault?: false }): string | null; + public avatarURL(options: UserAvatarURLOptions & { fallbackDefault: true }): string; + public avatarURL({ fallbackDefault = false, format, size }: UserAvatarURLOptions = {}): string | null { + if (!this.avatarHash) { + if (!fallbackDefault) return null; + return this.defaultAvatarURL; + } + let url = `${Constants.CDN_URL}/avatars/${this.id}/${this.avatarHash}.${ + typeof format === 'undefined' ? Util.resolveAvatarFormat(this.avatarHash) : format + }`; + if (typeof size === 'number') url += `?size=${size}`; + return url; + } + + public equals(data: APIUser | User): boolean { + const raw = data.id === this.id + && (data.bot || false) === this.bot + && data.discriminator === this.discriminator + && (data.system || false) === this.system + && data.username === this.username; + if (!raw) return false; + if ('avatar' in data) { + return data.avatar === this.avatarHash + && data.public_flags === this.publicFlags; + } else { + return data.avatarHash === this.avatarHash + && data.publicFlags === this.publicFlags; + } + } + + public fetch(): Promise { + return this.client.users.fetch(this, { force: true }); + } + + /** + * @internal Internal use only + */ + public _update(data: APIUser): void { + if ('avatar' in data) { + this.avatarHash = data.avatar; + } else if (typeof this.avatarHash === 'undefined') { + this.avatarHash = null; + } + + if ('bot' in data) { + this.bot = data.bot!; + } else if (typeof this.bot === 'undefined') { + // assume bot is false, API only sends `bot` if its true + this.bot = false; + } + + if ('discriminator' in data) { + this.discriminator = data.discriminator; + } + + if ('public_flags' in data) { + this.publicFlags = data.public_flags!; + } + + if ('system' in data) { + this.system = data.system!; + } else if (typeof this.system === 'undefined') { + // same as bot + this.system = false; + } + + if ('username' in data) { + this.username = data.username; + } + } +} \ No newline at end of file diff --git a/src/structures/index.ts b/src/structures/index.ts new file mode 100644 index 0000000..41c5de3 --- /dev/null +++ b/src/structures/index.ts @@ -0,0 +1,2 @@ +export { default as Base } from './Base'; +export { default as User } from './User'; \ No newline at end of file diff --git a/src/util/ValueCache.ts b/src/util/ValueCache.ts new file mode 100644 index 0000000..ba549d6 --- /dev/null +++ b/src/util/ValueCache.ts @@ -0,0 +1,59 @@ +// Slightly inspired by Discord.JS's collection package +// https://github.com/discordjs/collection +// Opted not to extend that as to use my own implementation for specific method + +export default class ValueCache extends Map { + public constructor(iterable?: Iterable | readonly (readonly [K, V])[]) { + // lazy type-fix + super(iterable!); + } + + public find(fn: (value: V, key: K, cache: this) => boolean): T | null { + for (const [key, value] of this) { + if (fn(value, key, this)) return value; + } + return null; + } + + public filter(fn: (value: V, key: K, cache: this) => boolean): ValueCache { + const filtered = new ValueCache(); + for (const [key, value] of this) { + if (fn(value, key, this)) filtered.set(key, value); + } + return filtered; + } + + /** + * Deletes values that meet the condition specified. + */ + public sweep(fn: (value: V, key: K, cache: this) => boolean): this { + for (const [key, value] of this) { + if (fn(value, key, this)) this.delete(key); + } + return this; + } + + /** + * Partitions the collection into multiple arrays. + * The return type for the callback should be the index of which that array + * should be in the returned array. + * use -1 to ignore value + * @example + * const [usersNamedJohn, usersNamedBob, usersNamedFred] = users.partition(value => { + * if (value.username === 'John') return 0; + * else if (value.username === 'Bob') return 1; + * else if (value.username === 'Fred') return 2; + * return -1; + * }) + */ + public partition(fn: (value: V, key: K, cache: this) => number): V[][] { + const arrays: V[][] = []; + for (const [key, value] of this) { + const index = fn(value, key, this); + if (index === -1) continue; + if (!arrays[index]) arrays[index] = [value]; + else arrays[index].push(value); + } + return arrays; + } +} \ No newline at end of file diff --git a/src/util/constants.ts b/src/util/constants.ts index 4ca5750..a8f08bd 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,6 +1,8 @@ import { ClientOptions } from '../client'; +import { GatewayDispatchEvents } from 'discord-api-types/v6'; -export const API_URL = 'http://discord.com/api'; +export const API_URL = 'https://discord.com/api'; +export const CDN_URL = 'https://cdn.discordapp.com'; export const API_VERSION = 7; export const GATEWAY_VERSION = 6; @@ -20,222 +22,23 @@ const mirror = (array: T[]) => { return obj; }; -export enum AuditLogEvent { - GUILD_UPDATE = 1, - CHANNEL_CREATE = 10, - CHANNEL_UPDATE = 11, - CHANNEL_DELETE = 12, - CHANNEL_OVERWRITE_CREATE = 13, - CHANNEL_OVERWRITE_UPDATE = 14, - CHANNEL_OVERWRITE_DELETE = 15, - MEMBER_KICK = 20, - MEMBER_PRUNE = 21, - MEMBER_BAN_ADD = 22, - MEMBER_BAN_REMOVE = 23, - MEMBER_UPDATE = 24, - MEMBER_ROLE_UPDATE = 25, - MEMBER_MOVE = 26, - MEMBER_DISCONNECT = 27, - BOT_ADD = 28, - ROLE_CREATE = 30, - ROLE_UPDATE = 31, - ROLE_DELETE = 32, - INVITE_CREATE = 40, - INVITE_UPDATE = 41, - INVITE_DELETE = 42, - WEBHOOK_CREATE = 50, - WEBHOOK_UPDATE = 51, - WEBHOOK_DELETE = 52, - EMOJI_CREATE = 60, - EMOJI_UPDATE = 61, - EMOJI_DELETE = 62, - MESSAGE_DELETE = 72, - MESSAGE_BULK_DELETE = 73, - MESSAGE_PIN = 74, - MESSAGE_UNPIN = 75, - INTEGRATION_CREATE = 80, - INTEGRATION_UPDATE = 81, - INTEGRATION_DELETE = 82, -} - -export enum ChannelType { - TEXT = 0, - DM = 1, - VOICE = 2, - GROUP_DM = 3, - CATEGORY = 4, - NEWS = 5, - STORE = 6 -} - -// types 1-5 cannot be achieved on a bot account, thus not covered here -export enum MessageType { - DEFAULT = 0, - CHANNEL_PINNED_MESSAGE = 6, - GUILD_MEMBER_JOIN = 7, - USER_PREMIUM_GUILD_SUBSCRIPTION = 8, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11, - CHANNEL_FOLLOW_ADD = 12, - GUILD_DISCOVERY_DISQUALIFIED = 14, - GUILD_DISCOVERY_REQUALIFIED = 15, -} - -export enum MessageActivityType { - JOIN = 1, - SPECTATE = 2, - LISTEN = 3, - // Type 4 is undocumented, unknown - JOIN_REQUEST = 4 -} - -export enum MessageFlag { - CROSSPOSTED = 1 << 0, - IS_CROSSPOST = 1 << 2, - SUPPRESS_EMBEDS = 1 << 3, - SOURCE_MESSAGE_DELETED = 1 << 4, - URGENT = 1 << 5 -} - -export enum PermissionFlag { - CREATE_INSTANT_INVITE = 1 << 0, - KICK_MEMBERS = 1 << 1, - BAN_MEMBERS = 1 << 2, - ADMINISTRATOR = 1 << 3, - MANAGE_CHANNELS = 1 << 4, - MANAGE_GUILD = 1 << 5, - ADD_REACTIONS = 1 << 6, - VIEW_AUDIT_LOG = 1 << 7, - PRIORITY_SPEAKER = 1 << 8, - STREAM = 1 << 9, - VIEW_CHANNEL = 1 << 10, - SEND_MESSAGES = 1 << 11, - SEND_TTS_MESSAGES = 1 << 12, - MANAGE_MESSAGES = 1 << 13, - EMBED_LINKS = 1 << 14, - ATTACH_FILES = 1 << 15, - READ_MESSAGE_HISTORY = 1 << 16, - MENTION_EVERYONE = 1 << 17, - USE_EXTERNAL_EMOJIS = 1 << 18, - VIEW_GUILD_INSIGHTS = 1 << 19, - CONNECT = 1 << 20, - SPEAK = 1 << 21, - MUTE_MEMBERS = 1 << 22, - DEAFEN_MEMBERS = 1 << 23, - MOVE_MEMBERS = 1 << 24, - USE_VAD = 1 << 25, - CHANGE_NICKNAME = 1 << 26, - MANAGE_NICKNAMES = 1 << 27, - MANAGE_ROLES = 1 << 28, - MANAGE_WEBHOOKS = 1 << 29, - MANAGE_EMOJIS = 1 << 30, -} - -export enum VerificationLevel { - NONE = 0, - LOW = 1, - MEDIUM = 2, - HIGH = 3, - VERY_HIGH = 4 -} - -export enum DefaultMessageNotification { - ALL_MESSAGES = 0, - ONLY_MENTIONS = 1 -} - -export enum ExplicitContentFilter { - DISABLED = 0, - MEMBERS_WITHOUT_ROLES = 1, - ALL_MEMBERS = 2 -} - -export enum MFALevel { - NONE = 0, - ELEVATED = 1 -} - -export enum SystemChannelFlag { - SUPPRESS_JOIN_NOTIFICATIONS = 1 << 0, - SUPPRESS_PREMIUM_NOTIFICATIONS = 1 << 1 -} - -export enum PremiumTier { - NONE = 0, - TIER_1 = 1, - TIER_2 = 2, - TIER_3 = 3 -} - -export enum IntegrationType { - YOUTUBE = 'youtube', - TWITCH = 'twitch', - DISCORD = 'discord' -} - -export enum IntegrationExpireBehavior { - REMOVE_ROLE = 0, - KICK = 1 -} - -export enum TeamMembershipState { - INVITED = 1, - ACCEPTED = 2 -} - -export enum WebhookType { - INCOMING = 1, - CHANNEL_FOLLOWER = 2 -} - -export const RequestMethod = mirror(['GET', 'DELETE', 'PATCH', 'PUT', 'POST']); -export type RequestMethod = keyof typeof RequestMethod; +export const RequestMethods = mirror(['GET', 'DELETE', 'PATCH', 'PUT', 'POST']); export enum GatewayStatus { NOT_CONNECTED = 0, CONNECTING = 1, - DISCONNECTED = 2, - RECONNECTING = 3, - CONNECTED = 4 + WAITING_FOR_GUILDS = 2, + DISCONNECTED = 3, + RECONNECTING = 4, + CONNECTED = 5 } -export enum OPCode { - DISPATCH = 0, - HEARTBEAT = 1, - IDENTIFY = 2, - PRESENCE_UPDATE = 3, - VOICE_STATE_UPDATE = 4, - RESUME = 6, - RECONNECT = 7, - REQUEST_GUILD_MEMBERS = 8, - INVALID_SESSION = 9, - HELLO = 10, - HEARTBEAT_ACK = 11 -} - -export enum PresenceStatus { - ONLINE = 'online', - DND = 'dnd', - IDLE = 'idle', - INVISIBLE = 'invisible', - OFFLINE = 'offlne' -} - -export enum ActivityType { - GAME = 0, - STREAMING = 1, - LISTENING = 2, - CUSTOM = 4 -} +export const WSEvents = mirror(Object.values(GatewayDispatchEvents)); -export const WSEventType = mirror([ - 'READY', 'CHANNEL_CREATE', 'CHANNEL_UPDATE', 'CHANNEL_DELETE', 'CHANNEL_PINS_UPDATE', - 'GUILD_CREATE', 'GUILD_UPDATE', 'GUILD_DELETE', 'GUILD_BAN_ADD', 'GUILD_BAN_REMOVE', - 'GUILD_EMOJIS_UPDATE', 'GUILD_INTEGRATIONS_UPDATE', 'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', - 'GUILD_MEMBER_UPDATE', 'GUILD_MEMBERS_CHUNK', 'GUILD_ROLE_CREATE', 'GUILD_ROLE_UPDATE', - 'GUILD_ROLE_DELETE', 'INVITE_CREATE', 'INVITE_DELETE', 'MESSAGE_CREATE', 'MESSAGE_UPDATE', - 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK', 'MESSAGE_REACTION_ADD', 'MESSAGE_REACTION_REMOVE', - 'MESSAGE_REACTION_REMOVE_ALL', 'MESSAGE_REACTION_REMOVE_EMOJI', 'PRESENCE_UPDATE', - 'TYPING_START', 'USER_UPDATE', 'VOICE_STATE_UPDATE', 'VOICE_SERVER_UPDATE', 'WEBHOOKS_UPDATE' -]); \ No newline at end of file +export enum ImageFormats { + WEBP = 'webp', + PNG = 'png', + JPG = 'jpg', + JPEG = 'jpeg', + GIF = 'gif' +} \ No newline at end of file diff --git a/src/util/index.ts b/src/util/index.ts index 2e76dd8..fc8a600 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,10 +1,15 @@ export * as Constants from './constants'; +export * from './types'; type Partialize = { [K in keyof T]?: T[K] extends object ? Partialize : T[K]; } -export class Util { +export default class Util { + static resolveAvatarFormat(hash: string): string { + return hash.startsWith('a_') ? 'gif' : 'webp'; + } + static mergeDefaults(toMerge: Partialize, defaults: T): T { for (const [key, _default] of Object.entries(defaults)) { const typeofDefault = typeof _default; diff --git a/src/util/types.ts b/src/util/types.ts new file mode 100644 index 0000000..6244367 --- /dev/null +++ b/src/util/types.ts @@ -0,0 +1,19 @@ +import { ImageFormats, RequestMethods } from './constants'; + +export interface FetchOptions { + cache?: boolean; + force?: boolean; +} + +export interface ImageURLOptions { + format?: ImageFormats; + size?: ImageSize; +} + +export interface UserAvatarURLOptions extends ImageURLOptions { + fallbackDefault?: boolean; +} + +export type ImageSize = 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096; + +export type RequestMethod = keyof typeof RequestMethods; \ No newline at end of file