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/mod.ts b/mod.ts index c62042f..8be44ca 100644 --- a/mod.ts +++ b/mod.ts @@ -1,6 +1,7 @@ export { GatewayIntents } from './src/types/gateway.ts' export { Base } from './src/structures/base.ts' export { Gateway } from './src/gateway/index.ts' +export type { GatewayTypedEvents } from './src/gateway/index.ts' export type { ClientEvents } from './src/gateway/handlers/index.ts' export * from './src/models/client.ts' export * from './src/models/slashClient.ts' @@ -124,3 +125,4 @@ export type { UserPayload } from './src/types/user.ts' export { UserFlags } from './src/types/userFlags.ts' export type { VoiceStatePayload } from './src/types/voice.ts' export type { WebhookPayload } from './src/types/webhook.ts' +export * from './src/models/collectors.ts' diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index b1a264b..4ffb8c7 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 { GuildChannels } from '../../types/guild.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: GuildChannels] + + 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/managers/guildChannels.ts b/src/managers/guildChannels.ts index 74cc8bf..9d86342 100644 --- a/src/managers/guildChannels.ts +++ b/src/managers/guildChannels.ts @@ -89,4 +89,20 @@ export class GuildChannelsManager extends BaseChildManager< const channel = await this.get(res.id) return (channel as unknown) as GuildChannels } + + /** Modify the positions of a set of channel positions for the guild. */ + async editPositions( + ...positions: Array<{ id: string | GuildChannels; position: number | null }> + ): Promise { + if (positions.length === 0) + throw new Error('No channel positions to change specified') + + await this.client.rest.api.guilds[this.guild.id].channels.patch( + positions.map((e) => ({ + id: typeof e.id === 'string' ? e.id : e.id.id, + position: e.position ?? null + })) + ) + return this + } } diff --git a/src/managers/guilds.ts b/src/managers/guilds.ts index 36166f6..7371267 100644 --- a/src/managers/guilds.ts +++ b/src/managers/guilds.ts @@ -1,5 +1,7 @@ +import { fetchAuto } from '../../deps.ts' import { Client } from '../models/client.ts' import { Guild } from '../structures/guild.ts' +import { Template } from '../structures/template.ts' import { Role } from '../structures/role.ts' import { GUILD, GUILDS, GUILD_PREVIEW } from '../types/endpoint.ts' import { @@ -16,7 +18,6 @@ import { } from '../types/guild.ts' import { BaseManager } from './base.ts' import { MembersManager } from './members.ts' -import { fetchAuto } from '../../deps.ts' import { Emoji } from '../structures/emoji.ts' export class GuildManager extends BaseManager { @@ -47,6 +48,19 @@ export class GuildManager extends BaseManager { }) } + /** Create a new guild based on a template. */ + async createFromTemplate( + template: Template | string, + name: string, + icon?: string + ): Promise { + if (icon?.startsWith('http') === true) icon = await fetchAuto(icon) + const guild = await this.client.rest.api.guilds.templates[ + typeof template === 'object' ? template.code : template + ].post({ name, icon }) + return new Guild(this.client, guild) + } + /** * Creates a guild. Returns Guild. Fires guildCreate event. * @param options Options for creating a guild diff --git a/src/managers/roles.ts b/src/managers/roles.ts index f4ad7f6..c1b371e 100644 --- a/src/managers/roles.ts +++ b/src/managers/roles.ts @@ -101,4 +101,20 @@ export class RolesManager extends BaseManager { return new Role(this.client, resp, this.guild) } + + /** Modify the positions of a set of role positions for the guild. */ + async editPositions( + ...positions: Array<{ id: string | Role; position: number | null }> + ): Promise { + if (positions.length === 0) + throw new Error('No role positions to change specified') + + await this.client.rest.api.guilds[this.guild.id].roles.patch( + positions.map((e) => ({ + id: typeof e.id === 'string' ? e.id : e.id.id, + position: e.position ?? null + })) + ) + return this + } } diff --git a/src/models/client.ts b/src/models/client.ts index 054d22b..efed48b 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,11 @@ 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' +import { VoiceRegion } from '../types/voice.ts' +import { fetchAuto } from '../../deps.ts' +import { DMChannel } from '../structures/dmChannel.ts' +import { Template } from '../structures/template.ts' /** OS related properties sent with Gateway Identify */ export interface ClientProperties { @@ -59,40 +63,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 +101,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 +134,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 +175,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 +189,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 }) + this.fetchGatewayInfo = options.fetchGatewayInfo ?? false + if (this.token === undefined) { try { const token = Deno.env.get('DISCORD_TOKEN') @@ -209,6 +220,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 +244,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 +284,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 +301,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 +345,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,32 +353,62 @@ 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) + /** Returns an array of voice region objects that can be used when creating servers. */ + async fetchVoiceRegions(): Promise { + return this.rest.api.voice.regions.get() + } + + /** Modify current (Client) User. */ + async editUser(data: { + username?: string + avatar?: string + }): Promise { + if (data.username === undefined && data.avatar === undefined) + throw new Error( + 'Either username or avatar or both must be specified to edit' + ) + + if (data.avatar?.startsWith('http') === true) { + data.avatar = await fetchAuto(data.avatar) + } + + await this.rest.api.users['@me'].patch({ + username: data.username, + avatar: data.avatar }) + return this + } + + /** Change Username of the Client User */ + async setUsername(username: string): Promise { + return await this.editUser({ username }) + } + + /** Change Avatar of the Client User */ + async setAvatar(avatar: string): Promise { + return await this.editUser({ avatar }) + } + + /** Create a DM Channel with a User */ + async createDM(user: User | string): Promise { + const id = typeof user === 'object' ? user.id : user + const dmPayload = await this.rest.api.users['@me'].channels.post({ + recipient_id: id + }) + await this.channels.set(dmPayload.id, dmPayload) + return (this.channels.get(dmPayload.id) as unknown) as DMChannel + } + + /** Returns a template object for the given code. */ + async fetchTemplate(code: string): Promise