From e7b0804616f5ad6a19b8c3d15b7cca9c2377393d Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Wed, 20 Jan 2021 15:35:15 +0530 Subject: [PATCH] feat: port to typed EventEmitter --- deps.ts | 2 +- src/gateway/handlers/index.ts | 49 ++++++++++- src/gateway/handlers/ready.ts | 1 + src/gateway/handlers/resume.ts | 2 +- src/gateway/index.ts | 110 +++++++++++++++--------- src/models/client.ts | 128 +++++++++++++++------------- src/models/collectors.ts | 15 +++- src/models/commandClient.ts | 30 +++---- src/models/extensions.ts | 7 +- src/models/shard.ts | 112 ++++++++++++------------ src/structures/guild.ts | 22 ++--- src/structures/guildVoiceChannel.ts | 8 +- src/utils/events.ts | 30 +++++++ 13 files changed, 318 insertions(+), 198 deletions(-) create mode 100644 src/utils/events.ts diff --git a/deps.ts b/deps.ts index 36d3504..bad924a 100644 --- a/deps.ts +++ b/deps.ts @@ -1,4 +1,4 @@ -export { EventEmitter } from 'https://deno.land/std@0.82.0/node/events.ts' +export { EventEmitter } from 'https://deno.land/x/event@0.2.1/mod.ts' export { unzlib } from 'https://deno.land/x/denoflate@1.1/mod.ts' export { fetchAuto } from 'https://raw.githubusercontent.com/DjDeveloperr/fetch-base64/main/mod.ts' export { parse } from 'https://deno.land/x/mutil@0.1.2/mod.ts' diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index b1a264b..45822e4 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -1,5 +1,9 @@ import { GatewayEventHandler } from '../index.ts' -import { GatewayEvents, TypingStartGuildData } from '../../types/gateway.ts' +import { + GatewayEvents, + MessageDeletePayload, + TypingStartGuildData +} from '../../types/gateway.ts' import { channelCreate } from './channelCreate.ts' import { channelDelete } from './channelDelete.ts' import { channelUpdate } from './channelUpdate.ts' @@ -55,6 +59,10 @@ import { } from '../../utils/getChannelByType.ts' import { interactionCreate } from './interactionCreate.ts' import { Interaction } from '../../structures/slash.ts' +import { CommandContext } from '../../models/command.ts' +import { RequestMethods } from '../../models/rest.ts' +import { PartialInvitePayload } from '../../types/invite.ts' +import { GuildChannel } from '../../managers/guildChannels.ts' export const gatewayHandlers: { [eventCode in GatewayEvents]: GatewayEventHandler | undefined @@ -105,7 +113,8 @@ export interface VoiceServerUpdateData { guild: Guild } -export interface ClientEvents { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ClientEvents = { /** When Client has successfully connected to Discord */ ready: [] /** When a successful reconnect has been made */ @@ -355,4 +364,40 @@ export interface ClientEvents { * @param payload Payload JSON of the event */ raw: [evt: string, payload: any] + + /** + * An uncached Message was deleted. + * @param payload Message Delete Payload + */ + messageDeleteUncached: [payload: MessageDeletePayload] + + guildMembersChunk: [ + guild: Guild, + info: { + chunkIndex: number + chunkCount: number + members: string[] + presences: string[] | undefined + } + ] + guildMembersChunked: [guild: Guild, chunks: number] + rateLimit: [data: { method: RequestMethods; url: string; body: any }] + inviteDeleteUncached: [invite: PartialInvitePayload] + voiceStateRemoveUncached: [data: { guild: Guild; member: Member }] + userUpdateUncached: [user: User] + webhooksUpdateUncached: [guild: Guild, channelID: string] + guildRoleUpdateUncached: [role: Role] + guildMemberUpdateUncached: [member: Member] + guildMemberRemoveUncached: [member: Member] + channelUpdateUncached: [channel: GuildChannel] + + commandOwnerOnly: [ctx: CommandContext] + commandGuildOnly: [ctx: CommandContext] + commandDmOnly: [ctx: CommandContext] + commandNSFW: [ctx: CommandContext] + commandBotMissingPermissions: [ctx: CommandContext, missing: string[]] + commandUserMissingPermissions: [ctx: CommandContext, missing: string[]] + commandMissingArgs: [ctx: CommandContext] + commandUsed: [ctx: CommandContext] + commandError: [ctx: CommandContext, err: Error] } diff --git a/src/gateway/handlers/ready.ts b/src/gateway/handlers/ready.ts index 58b6c9b..6f5085b 100644 --- a/src/gateway/handlers/ready.ts +++ b/src/gateway/handlers/ready.ts @@ -7,6 +7,7 @@ export const ready: GatewayEventHandler = async ( gateway: Gateway, d: Ready ) => { + gateway.client.upSince = new Date() await gateway.client.guilds.flush() await gateway.client.users.set(d.user.id, d.user) diff --git a/src/gateway/handlers/resume.ts b/src/gateway/handlers/resume.ts index ee71101..50d7c6b 100644 --- a/src/gateway/handlers/resume.ts +++ b/src/gateway/handlers/resume.ts @@ -8,7 +8,7 @@ export const resume: GatewayEventHandler = async ( d: Resume ) => { gateway.debug(`Session Resumed!`) - gateway.client.emit('resume') + gateway.client.emit('resumed') if (gateway.client.user === undefined) gateway.client.user = new User( gateway.client, diff --git a/src/gateway/index.ts b/src/gateway/index.ts index d13a1f7..9dc8031 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -1,4 +1,4 @@ -import { unzlib, EventEmitter } from '../../deps.ts' +import { unzlib } from '../../deps.ts' import { Client } from '../models/client.ts' import { DISCORD_GATEWAY_URL, @@ -10,14 +10,15 @@ import { GatewayIntents, GatewayCloseCodes, IdentityPayload, - StatusUpdatePayload + StatusUpdatePayload, + GatewayEvents } from '../types/gateway.ts' import { gatewayHandlers } from './handlers/index.ts' -import { GATEWAY_BOT } from '../types/endpoint.ts' import { GatewayCache } from '../managers/gatewayCache.ts' import { delay } from '../utils/delay.ts' import { VoiceChannel } from '../structures/guildVoiceChannel.ts' import { Guild } from '../structures/guild.ts' +import { HarmonyEventEmitter } from '../utils/events.ts' export interface RequestMembersOptions { limit?: number @@ -33,15 +34,31 @@ export interface VoiceStateOptions { export const RECONNECT_REASON = 'harmony-reconnect' +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type GatewayTypedEvents = { + [name in GatewayEvents]: [any] +} & { + connect: [] + ping: [number] + resume: [] + reconnectRequired: [] + close: [number, string] + error: [Error, ErrorEvent] + sentIdentify: [] + sentResume: [] + reconnecting: [] + init: [] +} + /** * Handles Discord Gateway connection. * * You should not use this and rather use Client class. */ -export class Gateway extends EventEmitter { - websocket: WebSocket - token: string - intents: GatewayIntents[] +export class Gateway extends HarmonyEventEmitter { + websocket?: WebSocket + token?: string + intents?: GatewayIntents[] connected = false initialized = false heartbeatInterval = 0 @@ -53,23 +70,13 @@ export class Gateway extends EventEmitter { client: Client cache: GatewayCache private timedIdentify: number | null = null + shards?: number[] - constructor(client: Client, token: string, intents: GatewayIntents[]) { + constructor(client: Client, shards?: number[]) { super() - this.token = token - this.intents = intents this.client = client this.cache = new GatewayCache(client) - this.websocket = new WebSocket( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${DISCORD_GATEWAY_URL}/?v=${DISCORD_API_VERSION}&encoding=json`, - [] - ) - this.websocket.binaryType = 'arraybuffer' - this.websocket.onopen = this.onopen.bind(this) - this.websocket.onmessage = this.onmessage.bind(this) - this.websocket.onclose = this.onclose.bind(this) - this.websocket.onerror = this.onerror.bind(this) + this.shards = shards } private onopen(): void { @@ -145,7 +152,7 @@ export class Gateway extends EventEmitter { await this.cache.set('seq', s) } if (t !== null && t !== undefined) { - this.emit(t, d) + this.emit(t as any, d) this.client.emit('raw', t, d) const handler = gatewayHandlers[t] @@ -236,24 +243,40 @@ export class Gateway extends EventEmitter { } } - private onerror(event: Event | ErrorEvent): void { - const eventError = event as ErrorEvent - this.emit('error', eventError) + private async onerror(event: ErrorEvent): Promise { + const error = new Error( + Deno.inspect({ + message: event.message, + error: event.error, + type: event.type, + target: event.target + }) + ) + error.name = 'ErrorEvent' + console.log(error) + this.emit('error', error, event) + await this.reconnect() } private async sendIdentify(forceNewSession?: boolean): Promise { - this.debug('Fetching /gateway/bot...') - const info = await this.client.rest.get(GATEWAY_BOT()) - if (info.session_start_limit.remaining === 0) - throw new Error( - `Session Limit Reached. Retry After ${info.session_start_limit.reset_after}ms` + if (typeof this.token !== 'string') throw new Error('Token not specified') + if (typeof this.intents !== 'object') + throw new Error('Intents not specified') + + if (this.client.fetchGatewayInfo === true) { + this.debug('Fetching /gateway/bot...') + const info = await this.client.rest.api.gateway.bot.get() + if (info.session_start_limit.remaining === 0) + throw new Error( + `Session Limit Reached. Retry After ${info.session_start_limit.reset_after}ms` + ) + this.debug(`Recommended Shards: ${info.shards}`) + this.debug('=== Session Limit Info ===') + this.debug( + `Remaining: ${info.session_start_limit.remaining}/${info.session_start_limit.total}` ) - this.debug(`Recommended Shards: ${info.shards}`) - this.debug('=== Session Limit Info ===') - this.debug( - `Remaining: ${info.session_start_limit.remaining}/${info.session_start_limit.total}` - ) - this.debug(`Reset After: ${info.session_start_limit.reset_after}ms`) + this.debug(`Reset After: ${info.session_start_limit.reset_after}ms`) + } if (forceNewSession === undefined || !forceNewSession) { const sessionIDCached = await this.cache.get('session_id') @@ -272,7 +295,10 @@ export class Gateway extends EventEmitter { $device: this.client.clientProperties.device ?? 'harmony' }, compress: true, - shard: [0, 1], // TODO: Make sharding possible + shard: + this.shards === undefined + ? [0, 1] + : [this.shards[0] ?? 0, this.shards[1] ?? 1], intents: this.intents.reduce( (previous, current) => previous | current, 0 @@ -289,6 +315,10 @@ export class Gateway extends EventEmitter { } private async sendResume(): Promise { + if (typeof this.token !== 'string') throw new Error('Token not specified') + if (typeof this.intents !== 'object') + throw new Error('Intents not specified') + if (this.sessionID === undefined) { this.sessionID = await this.cache.get('session_id') if (this.sessionID === undefined) return await this.sendIdentify() @@ -380,22 +410,22 @@ export class Gateway extends EventEmitter { this.websocket.onopen = this.onopen.bind(this) this.websocket.onmessage = this.onmessage.bind(this) this.websocket.onclose = this.onclose.bind(this) - this.websocket.onerror = this.onerror.bind(this) + this.websocket.onerror = this.onerror.bind(this) as any } close(code: number = 1000, reason?: string): void { - return this.websocket.close(code, reason) + return this.websocket?.close(code, reason) } send(data: GatewayResponse): boolean { - if (this.websocket.readyState !== this.websocket.OPEN) return false + if (this.websocket?.readyState !== this.websocket?.OPEN) return false const packet = JSON.stringify({ op: data.op, d: data.d, s: typeof data.s === 'number' ? data.s : null, t: data.t === undefined ? null : data.t }) - this.websocket.send(packet) + this.websocket?.send(packet) return true } diff --git a/src/models/client.ts b/src/models/client.ts index 054d22b..efbc478 100644 --- a/src/models/client.ts +++ b/src/models/client.ts @@ -3,7 +3,6 @@ import { User } from '../structures/user.ts' import { GatewayIntents } from '../types/gateway.ts' import { Gateway } from '../gateway/index.ts' import { RESTManager, RESTOptions, TokenType } from './rest.ts' -import { EventEmitter } from '../../deps.ts' import { DefaultCacheAdapter, ICacheAdapter } from './cacheAdapter.ts' import { UsersManager } from '../managers/users.ts' import { GuildManager } from '../managers/guilds.ts' @@ -21,6 +20,7 @@ import { Invite } from '../structures/invite.ts' import { INVITE } from '../types/endpoint.ts' import { ClientEvents } from '../gateway/handlers/index.ts' import type { Collector } from './collectors.ts' +import { HarmonyEventEmitter } from '../utils/events.ts' /** OS related properties sent with Gateway Identify */ export interface ClientProperties { @@ -59,40 +59,20 @@ export interface ClientOptions { disableEnvToken?: boolean /** Override REST Options */ restOptions?: RESTOptions -} - -export declare interface Client { - on( - event: K, - listener: (...args: ClientEvents[K]) => void - ): this - on(event: string | symbol, listener: (...args: any[]) => void): this - - once( - event: K, - listener: (...args: ClientEvents[K]) => void - ): this - once(event: string | symbol, listener: (...args: any[]) => void): this - - emit( - event: K, - ...args: ClientEvents[K] - ): boolean - emit(event: string | symbol, ...args: any[]): boolean - - off( - event: K, - listener: (...args: ClientEvents[K]) => void - ): this - off(event: string | symbol, listener: (...args: any[]) => void): this + /** Whether to fetch Gateway info or not */ + fetchGatewayInfo?: boolean + /** ADVANCED: Shard ID to launch on */ + shard?: number + /** Shard count. Set to 'auto' for automatic sharding */ + shardCount?: number | 'auto' } /** * Discord Client. */ -export class Client extends EventEmitter { +export class Client extends HarmonyEventEmitter { /** Gateway object */ - gateway?: Gateway + gateway: Gateway /** REST Manager - used to make all requests */ rest: RESTManager /** User which Client logs in to, undefined until logs in */ @@ -117,12 +97,21 @@ export class Client extends EventEmitter { clientProperties: ClientProperties /** Slash-Commands Management client */ slash: SlashClient + /** Whether to fetch Gateway info or not */ + fetchGatewayInfo: boolean = true + /** Users Manager, containing all Users cached */ users: UsersManager = new UsersManager(this) + /** Guilds Manager, providing cache & API interface to Guilds */ guilds: GuildManager = new GuildManager(this) + /** Channels Manager, providing cache interface to Channels */ channels: ChannelsManager = new ChannelsManager(this) + /** Channels Manager, providing cache interface to Channels */ emojis: EmojisManager = new EmojisManager(this) + /** Last READY timestamp */ + upSince?: Date + /** Client's presence. Startup one if set before connecting */ presence: ClientPresence = new ClientPresence() _decoratedEvents?: { @@ -141,10 +130,23 @@ export class Client extends EventEmitter { /** Shard on which this Client is */ shard: number = 0 + /** Shard Count */ + shardCount: number | 'auto' = 1 /** Shard Manager of this Client if Sharded */ - shardManager?: ShardManager + shards?: ShardManager + /** Collectors set */ collectors: Set = new Set() + /** Since when is Client online (ready). */ + get uptime(): number { + if (this.upSince === undefined) return 0 + else { + const dif = Date.now() - this.upSince.getTime() + if (dif < 0) return dif + else return dif + } + } + constructor(options: ClientOptions = {}) { super() this._id = options.id @@ -169,7 +171,7 @@ export class Client extends EventEmitter { Object.keys(this._decoratedEvents).length !== 0 ) { Object.entries(this._decoratedEvents).forEach((entry) => { - this.on(entry[0], entry[1]) + this.on(entry[0] as keyof ClientEvents, entry[1]) }) this._decoratedEvents = undefined } @@ -183,12 +185,17 @@ export class Client extends EventEmitter { } : options.clientProperties + if (options.shard !== undefined) this.shard = options.shard + if (options.shardCount !== undefined) this.shardCount = options.shardCount + this.slash = new SlashClient({ id: () => this.getEstimatedID(), client: this, enabled: options.enableSlash }) + if (options.fetchGatewayInfo === true) this.fetchGatewayInfo = true + if (this.token === undefined) { try { const token = Deno.env.get('DISCORD_TOKEN') @@ -209,6 +216,7 @@ export class Client extends EventEmitter { if (options.restOptions !== undefined) Object.assign(restOptions, options.restOptions) this.rest = new RESTManager(restOptions) + this.gateway = new Gateway(this) } /** @@ -232,6 +240,7 @@ export class Client extends EventEmitter { /** Emits debug event */ debug(tag: string, msg: string): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.emit('debug', `[${tag}] ${msg}`) } @@ -271,7 +280,7 @@ export class Client extends EventEmitter { * @param token Your token. This is required if not given in ClientOptions. * @param intents Gateway intents in array. This is required if not given in ClientOptions. */ - connect(token?: string, intents?: GatewayIntents[]): void { + async connect(token?: string, intents?: GatewayIntents[]): Promise { if (token === undefined && this.token !== undefined) token = this.token else if (this.token === undefined && token !== undefined) { this.token = token @@ -288,7 +297,30 @@ export class Client extends EventEmitter { } else throw new Error('No Gateway Intents were provided') this.rest.token = token - this.gateway = new Gateway(this, token, intents) + this.gateway.token = token + this.gateway.intents = intents + this.gateway.initWebsocket() + return this.waitFor('ready', () => true).then(() => this) + } + + /** Destroy the Gateway connection */ + async destroy(): Promise { + this.gateway.initialized = false + this.gateway.sequenceID = undefined + this.gateway.sessionID = undefined + await this.gateway.cache.delete('seq') + await this.gateway.cache.delete('session_id') + this.gateway.close() + this.user = undefined + this.upSince = undefined + return this + } + + /** Attempt to Close current Gateway connection and Resume */ + async reconnect(): Promise { + this.gateway.close() + this.gateway.initWebsocket() + return this.waitFor('ready', () => true).then(() => this) } /** Add a new Collector */ @@ -309,7 +341,7 @@ export class Client extends EventEmitter { } } - emit(event: keyof ClientEvents, ...args: any[]): boolean { + async emit(event: keyof ClientEvents, ...args: any[]): Promise { const collectors: Collector[] = [] for (const collector of this.collectors.values()) { if (collector.event === event) collectors.push(collector) @@ -317,33 +349,11 @@ export class Client extends EventEmitter { if (collectors.length !== 0) { this.collectors.forEach((collector) => collector._fire(...args)) } + // TODO(DjDeveloperr): Fix this ts-ignore + // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error + // @ts-ignore return super.emit(event, ...args) } - - /** Wait for an Event (optionally satisfying an event) to occur */ - async waitFor( - event: K, - checkFunction: (...args: ClientEvents[K]) => boolean, - timeout?: number - ): Promise { - return await new Promise((resolve) => { - let timeoutID: number | undefined - if (timeout !== undefined) { - timeoutID = setTimeout(() => { - this.off(event, eventFunc) - resolve([]) - }, timeout) - } - const eventFunc = (...args: ClientEvents[K]): void => { - if (checkFunction(...args)) { - resolve(args) - this.off(event, eventFunc) - if (timeoutID !== undefined) clearTimeout(timeoutID) - } - } - this.on(event, eventFunc) - }) - } } /** Event decorator to create an Event handler from function */ diff --git a/src/models/collectors.ts b/src/models/collectors.ts index 7e8a9b7..0b78518 100644 --- a/src/models/collectors.ts +++ b/src/models/collectors.ts @@ -1,6 +1,6 @@ import { Collection } from '../utils/collection.ts' -import { EventEmitter } from '../../deps.ts' import type { Client } from './client.ts' +import { HarmonyEventEmitter } from '../utils/events.ts' export type CollectorFilter = (...args: any[]) => boolean | Promise @@ -19,7 +19,14 @@ export interface CollectorOptions { timeout?: number } -export class Collector extends EventEmitter { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CollectorEvents = { + start: [] + end: [] + collect: any +} + +export class Collector extends HarmonyEventEmitter { client?: Client private _started: boolean = false event: string @@ -146,14 +153,14 @@ export class Collector extends EventEmitter { let done = false const onend = (): void => { done = true - this.removeListener('end', onend) + this.off('end', onend) resolve(this) } this.on('end', onend) setTimeout(() => { if (!done) { - this.removeListener('end', onend) + this.off('end', onend) reject(new Error('Timeout')) } }, timeout) diff --git a/src/models/commandClient.ts b/src/models/commandClient.ts index 28fba90..94d01ba 100644 --- a/src/models/commandClient.ts +++ b/src/models/commandClient.ts @@ -259,7 +259,7 @@ export class CommandClient extends Client implements CommandClientOptions { : category.ownerOnly) === true && !this.owners.includes(msg.author.id) ) - return this.emit('commandOwnerOnly', ctx, command) + return this.emit('commandOwnerOnly', ctx) // Checks if Command is only for Guild if ( @@ -268,7 +268,7 @@ export class CommandClient extends Client implements CommandClientOptions { : category.guildOnly) === true && msg.guild === undefined ) - return this.emit('commandGuildOnly', ctx, command) + return this.emit('commandGuildOnly', ctx) // Checks if Command is only for DMs if ( @@ -277,14 +277,14 @@ export class CommandClient extends Client implements CommandClientOptions { : category.dmOnly) === true && msg.guild !== undefined ) - return this.emit('commandDmOnly', ctx, command) + return this.emit('commandDmOnly', ctx) if ( command.nsfw === true && (msg.guild === undefined || ((msg.channel as unknown) as GuildTextChannel).nsfw !== true) ) - return this.emit('commandNSFW', ctx, command) + return this.emit('commandNSFW', ctx) const allPermissions = command.permissions !== undefined @@ -316,12 +316,7 @@ export class CommandClient extends Client implements CommandClientOptions { } if (missing.length !== 0) - return this.emit( - 'commandBotMissingPermissions', - ctx, - command, - missing - ) + return this.emit('commandBotMissingPermissions', ctx, missing) } } @@ -349,27 +344,22 @@ export class CommandClient extends Client implements CommandClientOptions { } if (missing.length !== 0) - return this.emit( - 'commandUserMissingPermissions', - command, - missing, - ctx - ) + return this.emit('commandUserMissingPermissions', ctx, missing) } } if (command.args !== undefined) { if (typeof command.args === 'boolean' && parsed.args.length === 0) - return this.emit('commandMissingArgs', ctx, command) + return this.emit('commandMissingArgs', ctx) else if ( typeof command.args === 'number' && parsed.args.length < command.args ) - this.emit('commandMissingArgs', ctx, command) + this.emit('commandMissingArgs', ctx) } try { - this.emit('commandUsed', ctx, command) + this.emit('commandUsed', ctx) const beforeExecute = await awaitSync(command.beforeExecute(ctx)) if (beforeExecute === false) return @@ -377,7 +367,7 @@ export class CommandClient extends Client implements CommandClientOptions { const result = await awaitSync(command.execute(ctx)) command.afterExecute(ctx, result) } catch (e) { - this.emit('commandError', command, ctx, e) + this.emit('commandError', ctx, e) } } } diff --git a/src/models/extensions.ts b/src/models/extensions.ts index 562100b..4496590 100644 --- a/src/models/extensions.ts +++ b/src/models/extensions.ts @@ -1,3 +1,4 @@ +import { ClientEvents } from '../../mod.ts' import { Collection } from '../utils/collection.ts' import { Command } from './command.ts' import { CommandClient } from './commandClient.ts' @@ -90,14 +91,14 @@ export class Extension { Object.keys(this._decoratedEvents).length !== 0 ) { Object.entries(this._decoratedEvents).forEach((entry) => { - this.listen(entry[0], entry[1]) + this.listen(entry[0] as keyof ClientEvents, entry[1]) }) this._decoratedEvents = undefined } } /** Listens for an Event through Extension. */ - listen(event: string, cb: ExtensionEventCallback): boolean { + listen(event: keyof ClientEvents, cb: ExtensionEventCallback): boolean { if (this.events[event] !== undefined) return false else { const fn = (...args: any[]): any => { @@ -152,7 +153,7 @@ export class ExtensionsManager { if (extension === undefined) return false extension.commands.deleteAll() for (const [k, v] of Object.entries(extension.events)) { - this.client.removeListener(k, v) + this.client.off(k as keyof ClientEvents, v) // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete extension.events[k] } diff --git a/src/models/shard.ts b/src/models/shard.ts index ac15475..cd6601b 100644 --- a/src/models/shard.ts +++ b/src/models/shard.ts @@ -1,69 +1,75 @@ import { Collection } from '../utils/collection.ts' -import { Client, ClientOptions } from './client.ts' -import {EventEmitter} from '../../deps.ts' +import { Client } from './client.ts' import { RESTManager } from './rest.ts' -// import { GATEWAY_BOT } from '../types/endpoint.ts' -// import { GatewayBotPayload } from '../types/gatewayBot.ts' +import { Gateway } from '../gateway/index.ts' +import { HarmonyEventEmitter } from '../utils/events.ts' +import { GatewayEvents } from '../types/gateway.ts' -// TODO(DjDeveloperr) -// I'm kinda confused; will continue on this later once -// Deno namespace in Web Worker is stable! -export interface ShardManagerOptions { - client: Client | typeof Client - token?: string - intents?: number[] - options?: ClientOptions - shards: number +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ShardManagerEvents = { + launch: [number] + shardReady: [number] + shardDisconnect: [number, number | undefined, string | undefined] + shardError: [number, Error, ErrorEvent] + shardResume: [number] } -export interface ShardManagerInitOptions { - file: string - token?: string - intents?: number[] - options?: ClientOptions - shards?: number -} - -export class ShardManager extends EventEmitter { - workers: Collection = new Collection() - token: string - intents: number[] - shardCount: number - private readonly __client: Client +export class ShardManager extends HarmonyEventEmitter { + list: Collection = new Collection() + client: Client + cachedShardCount?: number get rest(): RESTManager { - return this.__client.rest + return this.client.rest } - constructor(options: ShardManagerOptions) { + constructor(client: Client) { super() - this.__client = - options.client instanceof Client - ? options.client - : // eslint-disable-next-line new-cap - new options.client(options.options) - - if (this.__client.token === undefined || options.token === undefined) - throw new Error('Token should be provided when constructing ShardManager') - if (this.__client.intents === undefined || options.intents === undefined) - throw new Error( - 'Intents should be provided when constructing ShardManager' - ) - - this.token = this.__client.token ?? options.token - this.intents = this.__client.intents ?? options.intents - this.shardCount = options.shards + this.client = client } - // static async init(): Promise {} + async getShardCount(): Promise { + let shardCount: number + if (this.cachedShardCount !== undefined) shardCount = this.cachedShardCount + else { + if (this.client.shardCount === 'auto') { + const info = await this.client.rest.api.gateway.bot.get() + shardCount = info.shards as number + } else shardCount = this.client.shardCount ?? 1 + } + this.cachedShardCount = shardCount + return this.cachedShardCount + } - // async start(): Promise { - // const info = ((await this.rest.get( - // GATEWAY_BOT() - // )) as unknown) as GatewayBotPayload + /** Launches a new Shard */ + async launch(id: number): Promise { + if (this.list.has(id.toString()) === true) + throw new Error(`Shard ${id} already launched`) - // const totalShards = this.__shardCount ?? info.shards + const shardCount = await this.getShardCount() - // return this - // } + const gw = new Gateway(this.client, [Number(id), shardCount]) + this.list.set(id.toString(), gw) + gw.initWebsocket() + this.emit('launch', id) + + gw.on(GatewayEvents.Ready, () => this.emit('shardReady', id)) + gw.on('error', (err: Error, evt: ErrorEvent) => + this.emit('shardError', id, err, evt) + ) + gw.on(GatewayEvents.Resumed, () => this.emit('shardResume', id)) + gw.on('close', (code: number, reason: string) => + this.emit('shardDisconnect', id, code, reason) + ) + + return gw.waitFor(GatewayEvents.Ready, () => true).then(() => this) + } + + async start(): Promise { + const shardCount = await this.getShardCount() + for (let i = 0; i <= shardCount; i++) { + await this.launch(i) + } + return this + } } diff --git a/src/structures/guild.ts b/src/structures/guild.ts index 4fe7f9c..f749492 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -292,14 +292,14 @@ export class Guild extends Base { const listener = (guild: Guild): void => { if (guild.id === this.id) { chunked = true - this.client.removeListener('guildMembersChunked', listener) + this.client.off('guildMembersChunked', listener) resolve(this) } } this.client.on('guildMembersChunked', listener) setTimeout(() => { if (!chunked) { - this.client.removeListener('guildMembersChunked', listener) + this.client.off('guildMembersChunked', listener) } }, timeout) } @@ -312,19 +312,19 @@ export class Guild extends Base { */ async awaitAvailability(timeout: number = 1000): Promise { return await new Promise((resolve, reject) => { - if(!this.unavailable) resolve(this); + if (!this.unavailable) resolve(this) const listener = (guild: Guild): void => { if (guild.id === this.id) { - this.client.removeListener('guildLoaded', listener); - resolve(this); + this.client.off('guildLoaded', listener) + resolve(this) } - }; - this.client.on('guildLoaded', listener); + } + this.client.on('guildLoaded', listener) setTimeout(() => { - this.client.removeListener('guildLoaded', listener); - reject(Error("Timeout. Guild didn't arrive in time.")); - }, timeout); - }); + this.client.off('guildLoaded', listener) + reject(Error("Timeout. Guild didn't arrive in time.")) + }, timeout) + }) } } diff --git a/src/structures/guildVoiceChannel.ts b/src/structures/guildVoiceChannel.ts index eb5dc7e..4308c8c 100644 --- a/src/structures/guildVoiceChannel.ts +++ b/src/structures/guildVoiceChannel.ts @@ -44,7 +44,7 @@ export class VoiceChannel extends Channel { const onVoiceStateAdd = (state: VoiceState): void => { if (state.user.id !== this.client.user?.id) return if (state.channel?.id !== this.id) return - this.client.removeListener('voiceStateAdd', onVoiceStateAdd) + this.client.off('voiceStateAdd', onVoiceStateAdd) done++ if (done >= 2) resolve((vcdata as unknown) as VoiceServerUpdateData) } @@ -52,7 +52,7 @@ export class VoiceChannel extends Channel { const onVoiceServerUpdate = (data: VoiceServerUpdateData): void => { if (data.guild.id !== this.guild.id) return vcdata = data - this.client.removeListener('voiceServerUpdate', onVoiceServerUpdate) + this.client.off('voiceServerUpdate', onVoiceServerUpdate) done++ if (done >= 2) resolve(vcdata) } @@ -64,8 +64,8 @@ export class VoiceChannel extends Channel { setTimeout(() => { if (done < 2) { - this.client.removeListener('voiceServerUpdate', onVoiceServerUpdate) - this.client.removeListener('voiceStateAdd', onVoiceStateAdd) + this.client.off('voiceServerUpdate', onVoiceServerUpdate) + this.client.off('voiceStateAdd', onVoiceStateAdd) reject( new Error( "Connection timed out - couldn't connect to Voice Channel" diff --git a/src/utils/events.ts b/src/utils/events.ts new file mode 100644 index 0000000..e19cb5b --- /dev/null +++ b/src/utils/events.ts @@ -0,0 +1,30 @@ +import { EventEmitter } from '../../deps.ts' + +export class HarmonyEventEmitter< + T extends Record +> extends EventEmitter { + /** Wait for an Event to fire with given condition. */ + async waitFor( + event: K, + checkFunction: (...args: T[K]) => boolean = () => true, + timeout?: number + ): Promise { + return await new Promise((resolve) => { + let timeoutID: number | undefined + if (timeout !== undefined) { + timeoutID = setTimeout(() => { + this.off(event, eventFunc) + resolve([]) + }, timeout) + } + const eventFunc = (...args: T[K]): void => { + if (checkFunction(...args)) { + resolve(args) + this.off(event, eventFunc) + if (timeoutID !== undefined) clearTimeout(timeoutID) + } + } + this.on(event, eventFunc) + }) + } +}