From 48976e779be73b6792ae8b51ed85fc9d741ced3c Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Thu, 7 Jan 2021 19:16:56 +0530 Subject: [PATCH] collectors, rest options and all that --- mod.ts | 4 +- src/gateway/handlers/index.ts | 7 ++ src/models/client.ts | 76 ++++++++++++++-- src/models/collectors.ts | 162 ++++++++++++++++++++++++++++++++++ src/models/rest.ts | 126 +++++++++++++++++++++----- src/structures/webhook.ts | 6 +- src/test/index.ts | 21 +++++ src/test/music.ts | 5 +- src/test/slash.ts | 13 ++- 9 files changed, 381 insertions(+), 39 deletions(-) create mode 100644 src/models/collectors.ts diff --git a/mod.ts b/mod.ts index 55bee3e..34d688d 100644 --- a/mod.ts +++ b/mod.ts @@ -4,7 +4,9 @@ export { Gateway } 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' -export { RESTManager } from './src/models/rest.ts' +export { RESTManager, TokenType, HttpResponseCode } from './src/models/rest.ts' +export type { RequestHeaders } from './src/models/rest.ts' +export type { RESTOptions } from './src/models/rest.ts' export * from './src/models/cacheAdapter.ts' export { Command, diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index e305d9c..b1a264b 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -348,4 +348,11 @@ export interface ClientEvents { * @param message Debug message */ debug: [message: string] + + /** + * Raw event which gives you access to raw events DISPATCH'd from Gateway + * @param evt Event name string + * @param payload Payload JSON of the event + */ + raw: [evt: string, payload: any] } diff --git a/src/models/client.ts b/src/models/client.ts index a0b9272..054d22b 100644 --- a/src/models/client.ts +++ b/src/models/client.ts @@ -2,7 +2,7 @@ import { User } from '../structures/user.ts' import { GatewayIntents } from '../types/gateway.ts' import { Gateway } from '../gateway/index.ts' -import { RESTManager } from './rest.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' @@ -20,6 +20,7 @@ import { Application } from '../structures/application.ts' 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' /** OS related properties sent with Gateway Identify */ export interface ClientProperties { @@ -54,6 +55,10 @@ export interface ClientOptions { clientProperties?: ClientProperties /** Enable/Disable Slash Commands Integration (enabled by default) */ enableSlash?: boolean + /** Disable taking token from env if not provided (token is taken from env if present by default) */ + disableEnvToken?: boolean + /** Override REST Options */ + restOptions?: RESTOptions } export declare interface Client { @@ -89,7 +94,7 @@ export class Client extends EventEmitter { /** Gateway object */ gateway?: Gateway /** REST Manager - used to make all requests */ - rest: RESTManager = new RESTManager(this) + rest: RESTManager /** User which Client logs in to, undefined until logs in */ user?: User /** WebSocket ping of Client */ @@ -118,8 +123,6 @@ export class Client extends EventEmitter { channels: ChannelsManager = new ChannelsManager(this) emojis: EmojisManager = new EmojisManager(this) - /** Whether the REST Manager will use Canary API or not */ - canary: boolean = false /** Client's presence. Startup one if set before connecting */ presence: ClientPresence = new ClientPresence() _decoratedEvents?: { @@ -140,6 +143,7 @@ export class Client extends EventEmitter { shard: number = 0 /** Shard Manager of this Client if Sharded */ shardManager?: ShardManager + collectors: Set = new Set() constructor(options: ClientOptions = {}) { super() @@ -153,7 +157,6 @@ export class Client extends EventEmitter { options.presence instanceof ClientPresence ? options.presence : new ClientPresence(options.presence) - if (options.canary === true) this.canary = true if (options.messageCacheLifetime !== undefined) this.messageCacheLifetime = options.messageCacheLifetime if (options.reactionCacheLifetime !== undefined) @@ -185,6 +188,27 @@ export class Client extends EventEmitter { client: this, enabled: options.enableSlash }) + + if (this.token === undefined) { + try { + const token = Deno.env.get('DISCORD_TOKEN') + if (token !== undefined) { + this.token = token + this.debug('Info', 'Found token in ENV') + } + } catch (e) {} + } + + const restOptions: RESTOptions = { + token: () => this.token, + tokenType: TokenType.Bot, + canary: options.canary, + client: this + } + + if (options.restOptions !== undefined) + Object.assign(restOptions, options.restOptions) + this.rest = new RESTManager(restOptions) } /** @@ -244,8 +268,8 @@ export class Client extends EventEmitter { /** * This function is used for connecting to discord. - * @param token Your token. This is required. - * @param intents Gateway intents in array. This is required. + * @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 { if (token === undefined && this.token !== undefined) token = this.token @@ -262,9 +286,40 @@ export class Client extends EventEmitter { } else if (intents !== undefined && this.intents === undefined) { this.intents = intents } else throw new Error('No Gateway Intents were provided') + + this.rest.token = token this.gateway = new Gateway(this, token, intents) } + /** Add a new Collector */ + addCollector(collector: Collector): boolean { + if (this.collectors.has(collector)) return false + else { + this.collectors.add(collector) + return true + } + } + + /** Remove a Collector */ + removeCollector(collector: Collector): boolean { + if (!this.collectors.has(collector)) return false + else { + this.collectors.delete(collector) + return true + } + } + + emit(event: keyof ClientEvents, ...args: any[]): boolean { + const collectors: Collector[] = [] + for (const collector of this.collectors.values()) { + if (collector.event === event) collectors.push(collector) + } + if (collectors.length !== 0) { + this.collectors.forEach((collector) => collector._fire(...args)) + } + return super.emit(event, ...args) + } + /** Wait for an Event (optionally satisfying an event) to occur */ async waitFor( event: K, @@ -293,10 +348,13 @@ export class Client extends EventEmitter { /** Event decorator to create an Event handler from function */ export function event(name?: keyof ClientEvents) { - return function (client: Client | Extension, prop: keyof ClientEvents) { + return function ( + client: Client | Extension, + prop: keyof ClientEvents | string + ) { const listener = ((client as unknown) as { [name in keyof ClientEvents]: (...args: ClientEvents[name]) => any - })[prop] + })[name ?? ((prop as unknown) as keyof ClientEvents)] if (typeof listener !== 'function') throw new Error('@event decorator requires a function') if (client._decoratedEvents === undefined) client._decoratedEvents = {} diff --git a/src/models/collectors.ts b/src/models/collectors.ts new file mode 100644 index 0000000..7e8a9b7 --- /dev/null +++ b/src/models/collectors.ts @@ -0,0 +1,162 @@ +import { Collection } from '../utils/collection.ts' +import { EventEmitter } from '../../deps.ts' +import type { Client } from './client.ts' + +export type CollectorFilter = (...args: any[]) => boolean | Promise + +export interface CollectorOptions { + /** Event name to listen for */ + event: string + /** Optionally Client object for deinitOnEnd functionality */ + client?: Client + /** Filter function */ + filter?: CollectorFilter + /** Max entries to collect */ + max?: number + /** Whether or not to de-initialize on end */ + deinitOnEnd?: boolean + /** Timeout to end the Collector if not fulfilled if any filter or max */ + timeout?: number +} + +export class Collector extends EventEmitter { + client?: Client + private _started: boolean = false + event: string + filter: CollectorFilter = () => true + collected: Collection = new Collection() + max?: number + deinitOnEnd: boolean = false + timeout?: number + private _timer?: number + + get started(): boolean { + return this._started + } + + set started(d: boolean) { + if (d !== this._started) { + this._started = d + if (d) this.emit('start') + else { + if (this.deinitOnEnd && this.client !== undefined) + this.deinit(this.client) + this.emit('end') + } + } + } + + constructor(options: CollectorOptions | string) { + super() + if (typeof options === 'string') this.event = options + else { + this.event = options.event + this.client = options.client + this.filter = options.filter ?? (() => true) + this.max = options.max + this.deinitOnEnd = options.deinitOnEnd ?? false + this.timeout = options.timeout + } + } + + /** Start collecting */ + collect(): Collector { + this.started = true + if (this.client !== undefined) this.init(this.client) + if (this._timer !== undefined) clearTimeout(this._timer) + if (this.timeout !== undefined) { + this._timer = setTimeout(() => { + this.end() + }, this.timeout) + } + return this + } + + /** End collecting */ + end(): Collector { + this.started = false + if (this._timer !== undefined) clearTimeout(this._timer) + return this + } + + /** Reset collector and start again */ + reset(): Collector { + this.collected = new Collection() + this.collect() + return this + } + + /** Init the Collector on Client */ + init(client: Client): Collector { + this.client = client + client.addCollector(this) + return this + } + + /** De initialize the Collector i.e. remove cleanly */ + deinit(client: Client): Collector { + client.removeCollector(this) + return this + } + + /** Checks we may want to perform on an extended version of Collector */ + protected check(..._args: any[]): boolean | Promise { + return true + } + + /** Fire the Collector */ + async _fire(...args: any[]): Promise { + if (!this.started) return + const check = await this.check(...args) + if (!check) return + const filter = await this.filter(...args) + if (!filter) return + this.collected.set((Number(this.collected.size) + 1).toString(), args) + this.emit('collect', ...args) + if ( + this.max !== undefined && + // linter: send help + this.max < Number(this.collected.size) + 1 + ) { + this.end() + } + } + + /** Set filter of the Collector */ + when(filter: CollectorFilter): Collector { + this.filter = filter + return this + } + + /** Add a new listener for 'collect' event */ + each(handler: CallableFunction): Collector { + this.on('collect', () => handler()) + return this + } + + /** Returns a Promise resolved when Collector ends or a timeout occurs */ + async wait(timeout: number = this.timeout ?? 0): Promise { + return await new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!timeout) + throw new Error( + 'Timeout is required parameter if not given in CollectorOptions' + ) + + let done = false + const onend = (): void => { + done = true + this.removeListener('end', onend) + resolve(this) + } + + this.on('end', onend) + setTimeout(() => { + if (!done) { + this.removeListener('end', onend) + reject(new Error('Timeout')) + } + }, timeout) + }) + } +} diff --git a/src/models/rest.ts b/src/models/rest.ts index 29b708c..1b8ec7c 100644 --- a/src/models/rest.ts +++ b/src/models/rest.ts @@ -1,5 +1,6 @@ import * as baseEndpoints from '../consts/urlsAndVersions.ts' import { Collection } from '../utils/collection.ts' +import { Client } from './client.ts' export type RequestMethods = | 'get' @@ -60,15 +61,23 @@ export type MethodFunction = ( ) => Promise export interface APIMap extends MethodFunction { + /** Make a GET request to current route */ get: APIMap + /** Make a POST request to current route */ post: APIMap + /** Make a PATCH request to current route */ patch: APIMap + /** Make a PUT request to current route */ put: APIMap + /** Make a DELETE request to current route */ delete: APIMap + /** Make a HEAD request to current route */ head: APIMap + /** Continue building API Route */ [name: string]: APIMap } +/** API Route builder function */ export const builder = (rest: RESTManager, acum = '/'): APIMap => { const routes = {} const proxy = new Proxy(routes, { @@ -94,25 +103,76 @@ export const builder = (rest: RESTManager, acum = '/'): APIMap => { } export interface RESTOptions { - token?: string + /** Token to use for authorization */ + token?: string | (() => string | undefined) + /** Headers to patch with if any */ headers?: { [name: string]: string | undefined } + /** Whether to use Canary instance of Discord API or not */ canary?: boolean + /** Discord REST API version to use */ version?: 6 | 7 | 8 + /** Token Type to use for Authorization */ + tokenType?: TokenType + /** User Agent to use (Header) */ + userAgent?: string + /** Optional Harmony client */ + client?: Client } +/** Token Type for REST API. */ +export enum TokenType { + /** Token type for Bot User */ + Bot = 'Bot', + /** Token Type for OAuth2 */ + Bearer = 'Bearer', + /** No Token Type. Can be used for User accounts. */ + None = '' +} + +/** An easier to use interface for interacting with Discord REST API. */ export class RESTManager { - client?: RESTOptions queues: { [key: string]: QueuedItem[] } = {} rateLimits = new Collection() + /** Whether we are globally ratelimited or not */ globalRateLimit: boolean = false + /** Whether requests are being processed or not */ processing: boolean = false + /** API Version being used by REST Manager */ version: number = 8 + /** + * API Map - easy to use way for interacting with Discord API. + * + * Examples: + * * ```ts + * rest.api.users['123'].get().then(userPayload => doSomething) + * ``` + * * ```ts + * rest.api.guilds['123'].channels.post({ name: 'my-channel', type: 0 }).then(channelPayload => {}) + * ``` + */ api: APIMap + /** Token being used for Authorization */ + token?: string | (() => string | undefined) + /** Token Type of the Token if any */ + tokenType: TokenType = TokenType.Bot + /** Headers object which patch the current ones */ + headers: any = {} + /** Optional custom User Agent (header) */ + userAgent?: string + /** Whether REST Manager is using Canary API */ + canary?: boolean + /** Optional Harmony Client object */ + client?: Client - constructor(client?: RESTOptions) { - this.client = client + constructor(options?: RESTOptions) { this.api = builder(this) - if (client?.version !== undefined) this.version = client.version + if (options?.token !== undefined) this.token = options.token + if (options?.version !== undefined) this.version = options.version + if (options?.headers !== undefined) this.headers = options.headers + if (options?.tokenType !== undefined) this.tokenType = options.tokenType + if (options?.userAgent !== undefined) this.userAgent = options.userAgent + if (options?.canary !== undefined) this.canary = options.canary + if (options?.client !== undefined) this.client = options.client // eslint-disable-next-line @typescript-eslint/no-floating-promises this.handleRateLimits() } @@ -193,13 +253,16 @@ export class RESTManager { private prepare(body: any, method: RequestMethods): { [key: string]: any } { const headers: RequestHeaders = { - 'User-Agent': `DiscordBot (harmony, https://github.com/harmony-org/harmony)` + 'User-Agent': + this.userAgent ?? + `DiscordBot (harmony, https://github.com/harmony-org/harmony)` } - if (this.client !== undefined) - headers.Authorization = `Bot ${this.client.token}` - - if (this.client?.token === undefined) delete headers.Authorization + if (this.token !== undefined) { + const token = typeof this.token === 'string' ? this.token : this.token() + if (token !== undefined) + headers.Authorization = `${this.tokenType} ${token}`.trim() + } if (method === 'get' || method === 'head' || method === 'delete') body = undefined @@ -220,9 +283,7 @@ export class RESTManager { headers['Content-Type'] = 'application/json' } - if (this.client?.headers !== undefined) - Object.assign(headers, this.client.headers) - + if (this.headers !== undefined) Object.assign(headers, this.headers) const data: { [name: string]: any } = { headers, body: body?.file ?? JSON.stringify(body), @@ -305,13 +366,25 @@ export class RESTManager { body: any, data: { [key: string]: any }, reject: CallableFunction - ): Promise { + ): Promise { const status = response.status + // We have hit ratelimit - this should not happen + if (status === HttpResponseCode.TooManyRequests) { + if (this.client !== undefined) + this.client.emit('rateLimit', { + method: data.method, + url: response.url, + body + }) + reject(new Error('RateLimited')) + return + } + + // It's a normal status code... just continue if ( (status >= 200 && status < 400) || - status === HttpResponseCode.NoContent || - status === HttpResponseCode.TooManyRequests + status === HttpResponseCode.NoContent ) return @@ -322,9 +395,7 @@ export class RESTManager { if (status === HttpResponseCode.Unauthorized) reject( - new DiscordAPIError( - `Request was not successful (Unauthorized). Invalid Token.\n${text}` - ) + new DiscordAPIError(`Request was Unauthorized. Invalid Token.\n${text}`) ) // At this point we know it is error @@ -342,7 +413,7 @@ export class RESTManager { } }) ?? {} ).map((entry) => { - return [entry[0], entry[1]._errors] + return [entry[0], entry[1]._errors ?? []] }) ) } @@ -379,7 +450,7 @@ export class RESTManager { } /** - * Makes a Request to Discord API + * Makes a Request to Discord API. * @param method HTTP Method to use * @param url URL of the Request * @param body Body to send with Request @@ -422,7 +493,18 @@ export class RESTManager { let urlToUse = method === 'get' && query !== '' ? `${url}?${query}` : url - if (this.client?.canary === true) { + // It doesn't start with HTTP, that means it's an incomplete URL + if (!urlToUse.startsWith('http')) { + if (!urlToUse.startsWith('/')) urlToUse = `/${urlToUse}` + urlToUse = + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + baseEndpoints.DISCORD_API_URL + + '/v' + + baseEndpoints.DISCORD_API_VERSION + + urlToUse + } + + if (this.canary === true && urlToUse.startsWith('http')) { const split = urlToUse.split('//') urlToUse = split[0] + '//canary.' + split[1] } diff --git a/src/structures/webhook.ts b/src/structures/webhook.ts index 2d571c5..149827c 100644 --- a/src/structures/webhook.ts +++ b/src/structures/webhook.ts @@ -47,7 +47,9 @@ export class Webhook { rest: RESTManager get url(): string { - return `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/webhooks/${this.id}/${this.token}` + return `${DISCORD_API_URL}/v${ + this.rest.version ?? DISCORD_API_VERSION + }/webhooks/${this.id}/${this.token}` } constructor(data: WebhookPayload, client?: Client, rest?: RESTManager) { @@ -170,7 +172,7 @@ export class Webhook { * @param options Options to edit the Webhook. */ async edit(options: WebhookEditOptions): Promise { - if (options.channelID !== undefined && this.rest.client === undefined) + if (options.channelID !== undefined && this.client === undefined) throw new Error('Authentication is required for editing Webhook Channel') if ( options.avatar !== undefined && diff --git a/src/test/index.ts b/src/test/index.ts index 72a4fcf..9a72fd0 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -11,6 +11,7 @@ import { ChannelTypes, GuildTextChannel } from '../../mod.ts' +import { Collector } from '../models/collectors.ts' import { TOKEN } from './config.ts' const client = new Client({ @@ -122,6 +123,26 @@ client.on('messageCreate', async (msg: Message) => { ) msg.channel.send(`Received: ${receivedMsg?.content}`) + } else if (msg.content.startsWith('!collect') === true) { + let count = parseInt(msg.content.replace(/\D/g, '')) + if (isNaN(count)) count = 5 + await msg.channel.send(`Collecting ${count} messages for 5s`) + const coll = new Collector({ + event: 'messageCreate', + filter: (m) => m.author.id === msg.author.id, + deinitOnEnd: true, + max: count, + timeout: 5000 + }) + coll.init(client) + coll.collect() + coll.on('start', () => msg.channel.send('[COL] Started')) + coll.on('end', () => + msg.channel.send(`[COL] Ended. Collected Size: ${coll.collected.size}`) + ) + coll.on('collect', (msg) => + msg.channel.send(`[COL] Collect: ${msg.content}`) + ) } }) diff --git a/src/test/music.ts b/src/test/music.ts index af0333a..d6df917 100644 --- a/src/test/music.ts +++ b/src/test/music.ts @@ -11,10 +11,7 @@ import { GuildTextChannel } from '../../mod.ts' import { LL_IP, LL_PASS, LL_PORT, TOKEN } from './config.ts' -import { - Manager, - Player -} from '../../deps.ts' +import { Manager, Player } from 'https://deno.land/x/lavadeno/mod.ts' import { Interaction } from '../structures/slash.ts' import { slash } from '../models/client.ts' // import { SlashCommandOptionType } from '../types/slash.ts' diff --git a/src/test/slash.ts b/src/test/slash.ts index 07e9ac0..d673a10 100644 --- a/src/test/slash.ts +++ b/src/test/slash.ts @@ -9,6 +9,11 @@ export class MyClient extends Client { console.log(`Logged in as ${this.user?.tag}!`) } + @event('debug') + debugEvt(txt: string): void { + console.log(txt) + } + @slash() send(d: Interaction): void { d.respond({ @@ -92,5 +97,11 @@ export class MyClient extends Client { } } -const client = new MyClient() +const client = new MyClient({ + presence: { + status: 'dnd', + activity: { name: 'Slash Commands', type: 'LISTENING' } + } +}) + client.connect(TOKEN, Intents.None)