diff --git a/src/managers/emojis.ts b/src/managers/emojis.ts index 74abbe4..68f8088 100644 --- a/src/managers/emojis.ts +++ b/src/managers/emojis.ts @@ -10,6 +10,17 @@ export class EmojisManager extends BaseManager { super(client, `emojis`, Emoji) } + async get (key: string): Promise { + const raw = await this._get(key) + if (raw === undefined) return + const emoji = new this.DataType(this.client, raw) + if((raw as any).guild_id !== undefined) { + const guild = await this.client.guilds.get((raw as any).guild_id) + if(guild !== undefined) emoji.guild = guild + } + return emoji + } + async fetch (guildID: string, id: string): Promise { return await new Promise((resolve, reject) => { this.client.rest diff --git a/src/managers/guildEmojis.ts b/src/managers/guildEmojis.ts new file mode 100644 index 0000000..825b1ab --- /dev/null +++ b/src/managers/guildEmojis.ts @@ -0,0 +1,91 @@ +import { Client } from '../models/client.ts' +import { Emoji } from "../structures/emoji.ts" +import { Guild } from '../structures/guild.ts' +import { Role } from "../structures/role.ts" +import { EmojiPayload } from "../types/emoji.ts" +import { CHANNEL, GUILD_EMOJI, GUILD_EMOJIS } from '../types/endpoint.ts' +import { BaseChildManager } from './baseChild.ts' +import { EmojisManager } from "./emojis.ts" +import { fetchAuto } from 'https://raw.githubusercontent.com/DjDeveloperr/fetch-base64/main/mod.ts' + +export class GuildEmojisManager extends BaseChildManager< + EmojiPayload, + Emoji + > { + guild: Guild + + constructor(client: Client, parent: EmojisManager, guild: Guild) { + super(client, parent as any) + this.guild = guild + } + + async get(id: string): Promise { + const res = await this.parent.get(id) + if (res !== undefined && res.guild?.id === this.guild.id) return res + else return undefined + } + + async delete(id: string): Promise { + return this.client.rest.delete(CHANNEL(id)) + } + + async fetch(id: string): Promise { + return await new Promise((resolve, reject) => { + this.client.rest + .get(GUILD_EMOJI(this.guild.id, id)) + .then(async data => { + const emoji = new Emoji(this.client, data as EmojiPayload) + data.guild_id = this.guild.id + await this.set(id, data as EmojiPayload) + emoji.guild = this.guild + resolve(emoji) + }) + .catch(e => reject(e)) + }) + } + + async create(name: string, url: string, roles?: Role[] | string[] | string): Promise { + let data = url + if (!data.startsWith("data:")) { + data = await fetchAuto(url) + } + return await new Promise((resolve, reject) => { + let roleIDs: string[] = [] + if (roles !== undefined && typeof roles === "string") roleIDs = [roles] + else if (roles !== undefined) { + if(roles?.length === 0) reject(new Error("Empty Roles array was provided")) + if(roles[0] instanceof Role) roleIDs = (roles as any).map((r: Role) => r.id) + else roleIDs = roles as string[] + } else roles = [this.guild.id] + this.client.rest + .post(GUILD_EMOJIS(this.guild.id), { + name, + image: data, + roles: roleIDs + }) + .then(async data => { + const emoji = new Emoji(this.client, data as EmojiPayload) + data.guild_id = this.guild.id + await this.set(data.id, data as EmojiPayload) + emoji.guild = this.guild + resolve(emoji) + }) + .catch(e => reject(e)) + }) + } + + async array(): Promise { + const arr = (await this.parent.array()) as Emoji[] + return arr.filter( + (c: any) => c.guild !== undefined && c.guild.id === this.guild.id + ) as any + } + + async flush(): Promise { + const arr = await this.array() + for (const elem of arr) { + this.parent.delete(elem.id) + } + return true + } +} diff --git a/src/models/rest.ts b/src/models/rest.ts index 5458869..599df21 100644 --- a/src/models/rest.ts +++ b/src/models/rest.ts @@ -2,6 +2,9 @@ import { delay } from '../utils/index.ts' import * as baseEndpoints from '../consts/urlsAndVersions.ts' import { Client } from './client.ts' import { getBuildInfo } from '../utils/buildInfo.ts' +import { Collection } from "../utils/collection.ts" + +export type RequestMethods = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' export enum HttpResponseCode { Ok = 200, @@ -17,122 +20,102 @@ export enum HttpResponseCode { GatewayUnavailable = 502 } -export type RequestMethods = - | 'get' - | 'post' - | 'put' - | 'patch' - | 'head' - | 'delete' +export interface RequestHeaders { + [name: string]: string +} -export interface QueuedRequest { - callback: () => Promise< - | { - rateLimited: any - beforeFetch: boolean - bucketID?: string | null - } - | undefined - > - bucketID?: string | null +export interface QueuedItem { + onComplete: () => Promise<{ + rateLimited: any + bucket?: string | null + before: boolean + } | undefined> + bucket?: string | null url: string } -export interface RateLimitedPath { +export interface RateLimit { url: string - resetTimestamp: number - bucketID: string | null + resetAt: number + bucket: string | null } export class RESTManager { client: Client - globallyRateLimited: boolean = false - queueInProcess: boolean = false - pathQueues: { [key: string]: QueuedRequest[] } = {} - ratelimitedPaths = new Map() + queues: { [key: string]: QueuedItem[] } = {} + rateLimits = new Collection() + globalRateLimit: boolean = false + processing: boolean = false - constructor (client: Client) { + constructor(client: Client) { this.client = client - setTimeout(() => this.processRateLimitedPaths, 1000) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.handleRateLimits() } - async processRateLimitedPaths (): Promise { - const now = Date.now() - this.ratelimitedPaths.forEach((value, key) => { - if (value.resetTimestamp > now) return - this.ratelimitedPaths.delete(key) - if (key === 'global') this.globallyRateLimited = false + async checkQueues(): Promise { + Object.entries(this.queues).forEach(([key, value]) => { + if (value.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.queues[key] + } }) } - addToQueue (request: QueuedRequest): void { + queue(request: QueuedItem): void { const route = request.url.substring( // eslint seriously? // eslint-disable-next-line @typescript-eslint/restrict-plus-operands baseEndpoints.DISCORD_API_URL.length + 1 ) const parts = route.split('/') - // Remove the major param parts.shift() const [id] = parts - if (this.pathQueues[id] !== undefined) { - this.pathQueues[id].push(request) + if (this.queues[id] !== undefined) { + this.queues[id].push(request) } else { - this.pathQueues[id] = [request] + this.queues[id] = [request] } } - async cleanupQueues (): Promise { - Object.entries(this.pathQueues).forEach(([key, value]) => { - if (value.length === 0) { - // Remove it entirely - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete this.pathQueues[key] - } - }) - } - - async processQueue (): Promise { - if ( - Object.keys(this.pathQueues).length !== 0 && - !this.globallyRateLimited - ) { + async processQueue(): Promise { + if (Object.keys(this.queues).length !== 0 && !this.globalRateLimit) { await Promise.allSettled( - Object.values(this.pathQueues).map(async pathQueue => { + Object.values(this.queues).map(async pathQueue => { const request = pathQueue.shift() if (request === undefined) return - const rateLimitedURLResetIn = await this.checkRatelimits(request.url) + const rateLimitedURLResetIn = await this.isRateLimited(request.url) - if (typeof request.bucketID === 'string') { - const rateLimitResetIn = await this.checkRatelimits( - request.bucketID + if (typeof request.bucket === 'string') { + const rateLimitResetIn = await this.isRateLimited( + request.bucket ) if (rateLimitResetIn !== false) { // This request is still rate limited read to queue - this.addToQueue(request) + this.queue(request) } else { // This request is not rate limited so it should be run - const result = await request.callback() + const result = await request.onComplete() if (result?.rateLimited !== undefined) { - this.addToQueue({ + this.queue({ ...request, - bucketID: result.bucketID ?? request.bucketID + bucket: result.bucket ?? request.bucket }) } } } else { if (rateLimitedURLResetIn !== false) { // This URL is rate limited readd to queue - this.addToQueue(request) + this.queue(request) } else { // This request has no bucket id so it should be processed - const result = await request.callback() + const result = await request.onComplete() if (result?.rateLimited !== undefined) { - this.addToQueue({ + this.queue({ ...request, - bucketID: result.bucketID ?? request.bucketID + bucket: result.bucket ?? request.bucket }) } } @@ -141,27 +124,28 @@ export class RESTManager { ) } - if (Object.keys(this.pathQueues).length !== 0) { + if (Object.keys(this.queues).length !== 0) { await delay(1000) // eslint-disable-next-line @typescript-eslint/no-floating-promises this.processQueue() // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.cleanupQueues() - } else this.queueInProcess = false + this.checkQueues() + } else this.processing = false } - createRequestBody ( + prepare( body: any, method: RequestMethods ): { [key: string]: any } { - const headers: { [key: string]: string } = { - Authorization: `Bot ${this.client.token}`, - 'User-Agent': `DiscordBot (harmony)` + + const headers: RequestHeaders = { + 'Authorization': `Bot ${this.client.token}`, + 'User-Agent': `DiscordBot (harmony, https://github.com/harmony-org/harmony)` } if (this.client.token === undefined) delete headers.Authorization - if (method === 'get') body = undefined + if (method === 'get' || method === 'head' || method === 'delete') body = undefined if (body?.reason !== undefined) { headers['X-Audit-Log-Reason'] = encodeURIComponent(body.reason) @@ -203,117 +187,73 @@ export class RESTManager { return data } - async checkRatelimits (url: string): Promise { - const ratelimited = this.ratelimitedPaths.get(url) - const global = this.ratelimitedPaths.get('global') + async isRateLimited(url: string): Promise { + const global = this.rateLimits.get('global') + const rateLimited = this.rateLimits.get(url) const now = Date.now() - if (ratelimited !== undefined && now < ratelimited.resetTimestamp) { - return ratelimited.resetTimestamp - now + if (rateLimited !== undefined && now < rateLimited.resetAt) { + return rateLimited.resetAt - now } - if (global !== undefined && now < global.resetTimestamp) { - return global.resetTimestamp - now + if (global !== undefined && now < global.resetAt) { + return global.resetAt - now } return false } - async runMethod ( - method: RequestMethods, - url: string, - body?: unknown, - retryCount = 0, - bucketID?: string | null - ): Promise { - const errorStack = new Error('Location In Your Files:') - Error.captureStackTrace(errorStack) + processHeaders(url: string, headers: Headers): string | null | undefined { + let rateLimited = false - return await new Promise((resolve, reject) => { - const callback = async (): Promise => { - try { - const rateLimitResetIn = await this.checkRatelimits(url) - if (rateLimitResetIn !== false) { - return { - rateLimited: rateLimitResetIn, - beforeFetch: true, - bucketID - } - } + const global = headers.get('x-ratelimit-global') + const bucket = headers.get('x-ratelimit-bucket') + const remaining = headers.get('x-ratelimit-remaining') + const resetAt = headers.get('x-ratelimit-reset') + const retryAfter = headers.get('retry-after') - const query = - method === 'get' && body !== undefined - ? Object.entries(body as any) - .map( - ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent( - value as any - )}` - ) - .join('&') - : '' - let urlToUse = - method === 'get' && query !== '' ? `${url}?${query}` : url + if (remaining !== null && remaining === '0') { + rateLimited = true - if (this.client.canary === true) { - const split = urlToUse.split('//') - urlToUse = split[0] + '//canary.' + split[1] - } - - const requestData = this.createRequestBody(body, method) - - const response = await fetch(urlToUse, requestData) - const bucketIDFromHeaders = this.processHeaders(url, response.headers) - this.handleStatusCode(response, errorStack) - - // Sometimes Discord returns an empty 204 response that can't be made to JSON. - if (response.status === 204) return resolve(undefined) - - const json = await response.json() - if ( - json.retry_after !== undefined || - json.message === 'You are being rate limited.' - ) { - if (retryCount > 10) { - throw new Error('Max RateLimit Retries hit') - } - - return { - rateLimited: json.retry_after, - beforeFetch: false, - bucketID: bucketIDFromHeaders - } - } - return resolve(json) - } catch (error) { - return reject(error) - } - } - - this.addToQueue({ - callback, - bucketID, - url + this.rateLimits.set(url, { + url, + resetAt: Number(resetAt) * 1000, + bucket }) - if (!this.queueInProcess) { - this.queueInProcess = true - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.processQueue() + + if (bucket !== null) { + this.rateLimits.set(bucket, { + url, + resetAt: Number(resetAt) * 1000, + bucket + }) } - }) - } - - async logErrors (response: Response, errorStack?: unknown): Promise { - try { - const error = await response.json() - console.error(error) - } catch { - console.error(response) } + + if (global !== null) { + const reset = Date.now() + Number(retryAfter) + this.globalRateLimit = true + rateLimited = true + + this.rateLimits.set('global', { + url: 'global', + resetAt: reset, + bucket + }) + + if (bucket !== null) { + this.rateLimits.set(bucket, { + url: 'global', + resetAt: reset, + bucket + }) + } + } + + return rateLimited ? bucket : undefined } - handleStatusCode ( - response: Response, - errorStack?: unknown + handleStatusCode( + response: Response ): undefined | boolean { const status = response.status @@ -324,9 +264,6 @@ export class RESTManager { return true } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.logErrors(response, errorStack) - if (status === HttpResponseCode.Unauthorized) throw new Error('Request was not successful. Invalid Token.') @@ -345,76 +282,112 @@ export class RESTManager { throw new Error('Request Unknown Error') } - processHeaders (url: string, headers: Headers): string | null | undefined { - let ratelimited = false + async make( + method: RequestMethods, + url: string, + body?: unknown, + retryCount = 0, + bucket?: string | null + ): Promise { + return await new Promise((resolve, reject) => { + const onComplete = async (): Promise => { + try { + const rateLimitResetIn = await this.isRateLimited(url) + if (rateLimitResetIn !== false) { + return { + rateLimited: rateLimitResetIn, + before: true, + bucket + } + } - // Get all useful headers - const remaining = headers.get('x-ratelimit-remaining') - const resetTimestamp = headers.get('x-ratelimit-reset') - const retryAfter = headers.get('retry-after') - const global = headers.get('x-ratelimit-global') - const bucketID = headers.get('x-ratelimit-bucket') + const query = + method === 'get' && body !== undefined + ? Object.entries(body as any) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent( + value as any + )}` + ) + .join('&') + : '' + let urlToUse = + method === 'get' && query !== '' ? `${url}?${query}` : url - // If there is no remaining rate limit for this endpoint, we save it in cache - if (remaining !== null && remaining === '0') { - ratelimited = true + if (this.client.canary === true) { + const split = urlToUse.split('//') + urlToUse = split[0] + '//canary.' + split[1] + } - this.ratelimitedPaths.set(url, { - url, - resetTimestamp: Number(resetTimestamp) * 1000, - bucketID - }) + const requestData = this.prepare(body, method) - if (bucketID !== null) { - this.ratelimitedPaths.set(bucketID, { - url, - resetTimestamp: Number(resetTimestamp) * 1000, - bucketID - }) + const response = await fetch(urlToUse, requestData) + const bucketFromHeaders = this.processHeaders(url, response.headers) + this.handleStatusCode(response) + + if (response.status === 204) return resolve(undefined) + + const json = await response.json() + if ( + json.retry_after !== undefined || + json.message === 'You are being rate limited.' + ) { + if (retryCount > 10) { + throw new Error('Max RateLimit Retries hit') + } + + return { + rateLimited: json.retry_after, + before: false, + bucket: bucketFromHeaders + } + } + return resolve(json) + } catch (error) { + return reject(error) + } } - } - // If there is no remaining global limit, we save it in cache - if (global !== null) { - const reset = Date.now() + Number(retryAfter) - this.globallyRateLimited = true - ratelimited = true - - this.ratelimitedPaths.set('global', { - url: 'global', - resetTimestamp: reset, - bucketID + this.queue({ + onComplete, + bucket, + url }) - - if (bucketID !== null) { - this.ratelimitedPaths.set(bucketID, { - url: 'global', - resetTimestamp: reset, - bucketID - }) + if (!this.processing) { + this.processing = true + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.processQueue() } - } - - return ratelimited ? bucketID : undefined + }) } - async get (url: string, body?: unknown): Promise { - return await this.runMethod('get', url, body) + async handleRateLimits(): Promise { + const now = Date.now() + this.rateLimits.forEach((value, key) => { + if (value.resetAt > now) return + this.rateLimits.delete(key) + if (key === 'global') this.globalRateLimit = false + }) } - async post (url: string, body?: unknown): Promise { - return await this.runMethod('post', url, body) + async get(url: string, body?: unknown): Promise { + return await this.make('get', url, body) } - async delete (url: string, body?: unknown): Promise { - return await this.runMethod('delete', url, body) + async post(url: string, body?: unknown): Promise { + return await this.make('post', url, body) } - async patch (url: string, body?: unknown): Promise { - return await this.runMethod('patch', url, body) + async delete(url: string, body?: unknown): Promise { + return await this.make('delete', url, body) } - async put (url: string, body?: unknown): Promise { - return await this.runMethod('put', url, body) + async patch(url: string, body?: unknown): Promise { + return await this.make('patch', url, body) + } + + async put(url: string, body?: unknown): Promise { + return await this.make('put', url, body) } } diff --git a/src/structures/emoji.ts b/src/structures/emoji.ts index 8836993..6c49918 100644 --- a/src/structures/emoji.ts +++ b/src/structures/emoji.ts @@ -2,6 +2,7 @@ import { Client } from '../models/client.ts' import { EmojiPayload } from '../types/emoji.ts' import { USER } from '../types/endpoint.ts' import { Base } from './base.ts' +import { Guild } from "./guild.ts" import { User } from './user.ts' export class Emoji extends Base { @@ -9,6 +10,7 @@ export class Emoji extends Base { name: string roles?: string[] user?: User + guild?: Guild requireColons?: boolean managed?: boolean animated?: boolean @@ -20,17 +22,16 @@ export class Emoji extends Base { } else return `` } + toString(): string { + return this.getEmojiString + } + constructor (client: Client, data: EmojiPayload) { super(client, data) this.id = data.id this.name = data.name + if(data.user !== undefined) this.user = new User(this.client, data.user) this.roles = data.roles - if (data.user !== undefined) { - User.autoInit(this.client, { - endpoint: USER, - restURLfuncArgs: [data.user.id] - }).then(user => (this.user = user)) - } this.requireColons = data.require_colons this.managed = data.managed this.animated = data.animated diff --git a/src/structures/guild.ts b/src/structures/guild.ts index b01dc73..ab43c9a 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -6,7 +6,8 @@ import { VoiceState } from './voiceState.ts' import { RolesManager } from '../managers/roles.ts' import { GuildChannelsManager } from '../managers/guildChannels.ts' import { MembersManager } from '../managers/members.ts' -import { Emoji } from "./emoji.ts" +import { Role } from "./role.ts" +import { GuildEmojisManager } from "../managers/guildEmojis.ts" export class Guild extends Base { id: string @@ -27,7 +28,7 @@ export class Guild extends Base { defaultMessageNotifications?: string explicitContentFilter?: string roles: RolesManager - emojis: Emoji[] = [] + emojis: GuildEmojisManager features?: GuildFeatures[] mfaLevel?: string applicationID?: string @@ -66,6 +67,7 @@ export class Guild extends Base { this ) this.roles = new RolesManager(this.client, this) + this.emojis = new GuildEmojisManager(this.client, this.client.emojis, this) if (!this.unavailable) { this.name = data.name @@ -208,4 +210,8 @@ export class Guild extends Base { data.approximate_presence_count ?? this.approximatePresenceCount } } + + async getEveryoneRole(): Promise { + return (await this.roles.array().then(arr => arr?.sort((b, a) => a.position - b.position)[0]) as any) as Role + } } diff --git a/src/test/cmd.ts b/src/test/cmd.ts index d87d21d..8d4a948 100644 --- a/src/test/cmd.ts +++ b/src/test/cmd.ts @@ -1,5 +1,6 @@ import { CommandClient, Intents } from '../../mod.ts' import PingCommand from './cmds/ping.ts' +import AddEmojiCommand from './cmds/addemoji.ts' import UserinfoCommand from './cmds/userinfo.ts' import { TOKEN } from './config.ts' @@ -14,9 +15,10 @@ client.on('ready', () => { console.log(`[Login] Logged in as ${client.user?.tag}!`) }) -client.on("commandError", console.log) +client.on("commandError", console.error) client.commands.add(PingCommand) client.commands.add(UserinfoCommand) +client.commands.add(AddEmojiCommand) client.connect(TOKEN, Intents.All) \ No newline at end of file diff --git a/src/test/cmds/addemoji.ts b/src/test/cmds/addemoji.ts new file mode 100644 index 0000000..e2eb669 --- /dev/null +++ b/src/test/cmds/addemoji.ts @@ -0,0 +1,22 @@ +import { Command } from '../../../mod.ts' +import { CommandContext } from '../../models/command.ts' + +export default class AddEmojiCommand extends Command { + name = 'addemoji' + aliases = [ 'ae', 'emojiadd' ] + args = 2 + guildOnly = true + + execute(ctx: CommandContext): any { + const name = ctx.args[0] + if(name === undefined) return ctx.message.reply('No name was given!') + const url = ctx.argString.slice(name.length).trim() + if(url === '') return ctx.message.reply('No URL was given!') + ctx.message.guild?.emojis.create(name, url).then(emoji => { + if(emoji === undefined) throw new Error('Unknown') + ctx.message.reply(`Successfuly added emoji ${emoji.toString()} ${emoji.name}!`) + }).catch(e => { + ctx.message.reply(`Failed to add emoji. Reason: ${e.message}`) + }) + } +} \ No newline at end of file diff --git a/src/types/endpoint.ts b/src/types/endpoint.ts index c8ccdfe..a34575a 100644 --- a/src/types/endpoint.ts +++ b/src/types/endpoint.ts @@ -14,6 +14,8 @@ const GUILD_WIDGET = (guildID: string): string => `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/guilds/${guildID}/widget` const GUILD_EMOJI = (guildID: string, emojiID: string): string => `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/guilds/${guildID}/emojis/${emojiID}` +const GUILD_EMOJIS = (guildID: string): string => + `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/guilds/${guildID}/emojis` const GUILD_ROLE = (guildID: string, roleID: string): string => `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/guilds/${guildID}/roles/${roleID}` const GUILD_ROLES = (guildID: string): string => @@ -172,8 +174,6 @@ const TEAM_ICON = (teamID: string, iconID: string): string => // Emoji Endpoints const EMOJI = (guildID: string, emojiID: string): string => `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/guilds/${guildID}/emojis/${emojiID}` -const EMOJIS = (guildID: string): string => - `${DISCORD_API_URL}/v${DISCORD_API_VERSION}/guilds/${guildID}/emojis` // Template Endpoint const TEMPLATE = (templateCODE: string): string => @@ -259,7 +259,7 @@ export default [ ACHIEVEMENT_ICON, TEAM_ICON, EMOJI, - EMOJIS, + GUILD_EMOJIS, TEMPLATE, INVITE, VOICE_REGIONS @@ -305,6 +305,7 @@ export { CHANNEL_PIN, CHANNEL_PINS, CHANNEL_PERMISSION, + GUILD_EMOJIS, CHANNEL_TYPING, GROUP_RECIPIENT, CURRENT_USER, @@ -333,7 +334,6 @@ export { ACHIEVEMENT_ICON, TEAM_ICON, EMOJI, - EMOJIS, TEMPLATE, INVITE, VOICE_REGIONS