diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6fed3d0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7db4502..233ca92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "deno.import_intellisense_origins": { "https://deno.land": true }, - "editor.tabSize": 2 + "editor.tabSize": 2, + "editor.formatOnSave": true } \ No newline at end of file diff --git a/src/consts/urlsAndVersions.ts b/src/consts/urlsAndVersions.ts index 4c3c239..257c937 100644 --- a/src/consts/urlsAndVersions.ts +++ b/src/consts/urlsAndVersions.ts @@ -1,7 +1,9 @@ -export const DISCORD_API_URL: string = 'https://discord.com/api' - -export const DISCORD_GATEWAY_URL: string = 'wss://gateway.discord.gg' - -export const DISCORD_CDN_URL: string = 'https://cdn.discordapp.com' - -export const DISCORD_API_VERSION: number = 8 +export const DISCORD_API_URL: string = 'https://discord.com/api' + +export const DISCORD_GATEWAY_URL: string = 'wss://gateway.discord.gg' + +export const DISCORD_CDN_URL: string = 'https://cdn.discordapp.com' + +export const DISCORD_API_VERSION: number = 8 + +export const DISCORD_VOICE_VERSION: number = 4 \ No newline at end of file diff --git a/src/gateway/handlers/guildMembersChunk.ts b/src/gateway/handlers/guildMembersChunk.ts new file mode 100644 index 0000000..3cf3f3c --- /dev/null +++ b/src/gateway/handlers/guildMembersChunk.ts @@ -0,0 +1,31 @@ +import { Gateway, GatewayEventHandler } from '../index.ts' +import { Guild } from '../../structures/guild.ts' +import { GuildMemberChunkPayload } from '../../types/gateway.ts' + +export const guildMembersChunk: GatewayEventHandler = async ( + gateway: Gateway, + d: GuildMemberChunkPayload +) => { + const guild: Guild | undefined = await gateway.client.guilds.get(d.guild_id) + // Weird case, shouldn't happen + if (guild === undefined) return + + for (const member of d.members) { + await guild.members.set(member.user.id, member) + } + + // TODO: Cache Presences + + gateway.client.emit('guildMembersChunk', guild, { + members: d.members.map((m) => m.user.id), + presences: + d.presences === undefined ? undefined : d.presences.map((p) => p.user.id), + chunkIndex: d.chunk_index, + chunkCount: d.chunk_count, + }) + + // Guild is now completely chunked. Emit an event for that. + if (d.chunk_index >= d.chunk_count - 1) { + gateway.client.emit('guildMembersChunked', guild, d.chunk_count) + } +} diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 319c3f9..9974fff 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -36,6 +36,9 @@ import { Member } from "../../structures/member.ts" import { Role } from "../../structures/role.ts" import { Message } from "../../structures/message.ts" import { Collection } from "../../utils/collection.ts" +import { voiceServerUpdate } from "./voiceServerUpdate.ts" +import { voiceStateUpdate } from "./voiceStateUpdate.ts" +import { VoiceState } from "../../structures/voiceState.ts" export const gatewayHandlers: { [eventCode in GatewayEvents]: GatewayEventHandler | undefined @@ -74,7 +77,8 @@ export const gatewayHandlers: { PRESENCE_UPDATE: undefined, TYPING_START: typingStart, USER_UPDATE: userUpdate, - VOICE_SERVER_UPDATE: undefined, + VOICE_STATE_UPDATE: voiceStateUpdate, + VOICE_SERVER_UPDATE: voiceServerUpdate, WEBHOOKS_UPDATE: webhooksUpdate } @@ -82,6 +86,12 @@ export interface EventTypes { [name: string]: (...args: any[]) => void } +export interface VoiceServerUpdateData { + token: string + endpoint: string + guild: Guild +} + export interface ClientEvents extends EventTypes { 'ready': () => void 'reconnect': () => void @@ -111,5 +121,9 @@ export interface ClientEvents extends EventTypes { 'messageUpdate': (before: Message, after: Message) => void 'typingStart': (user: User, channel: TextChannel, at: Date, guildData?: TypingStartGuildData) => void 'userUpdate': (before: User, after: User) => void + 'voiceServerUpdate': (data: VoiceServerUpdateData) => void + 'voiceStateAdd': (state: VoiceState) => void + 'voiceStateRemove': (state: VoiceState) => void + 'voiceStateUpdate': (state: VoiceState, after: VoiceState) => void 'webhooksUpdate': (guild: Guild, channel: GuildTextChannel) => void } \ No newline at end of file diff --git a/src/gateway/handlers/voiceServerUpdate.ts b/src/gateway/handlers/voiceServerUpdate.ts new file mode 100644 index 0000000..6a17138 --- /dev/null +++ b/src/gateway/handlers/voiceServerUpdate.ts @@ -0,0 +1,14 @@ +import { Guild } from "../../structures/guild.ts" +import { VoiceServerUpdatePayload } from "../../types/gateway.ts" +import { Gateway, GatewayEventHandler } from '../index.ts' + +export const voiceServerUpdate: GatewayEventHandler = async ( + gateway: Gateway, + d: VoiceServerUpdatePayload +) => { + gateway.client.emit('voiceServerUpdate', { + token: d.token, + endpoint: d.endpoint, + guild: (await gateway.client.guilds.get(d.guild_id) as unknown) as Guild + }) +} diff --git a/src/gateway/handlers/voiceStateUpdate.ts b/src/gateway/handlers/voiceStateUpdate.ts new file mode 100644 index 0000000..fb8b95a --- /dev/null +++ b/src/gateway/handlers/voiceStateUpdate.ts @@ -0,0 +1,30 @@ +import { Guild } from "../../structures/guild.ts" +import { VoiceState } from "../../structures/voiceState.ts" +import { VoiceStatePayload } from "../../types/voice.ts" +import { Gateway, GatewayEventHandler } from '../index.ts' + +export const voiceStateUpdate: GatewayEventHandler = async ( + gateway: Gateway, + d: VoiceStatePayload +) => { + // TODO(DjDeveloperr): Support self-bot here; they can be in DMs (Call) + if (d.guild_id === undefined) return + const guild = (await gateway.client.guilds.get(d.guild_id) as unknown) as Guild + + const voiceState = await guild.voiceStates.get(d.user_id) + + if (d.channel_id === null) { + // No longer in the channel, so delete + await guild.voiceStates.delete(d.user_id) + gateway.client.emit('voiceStateRemove', (voiceState as unknown) as VoiceState) + return + } + + await guild.voiceStates.set(d.user_id, d) + const newVoiceState = await guild.voiceStates.get(d.user_id) + if (voiceState === undefined) { + gateway.client.emit('voiceStateAdd', (newVoiceState as unknown) as VoiceState) + } else { + gateway.client.emit('voiceStateUpdate', voiceState, (newVoiceState as unknown) as VoiceState) + } +} diff --git a/src/gateway/index.ts b/src/gateway/index.ts index cc16157..4af3912 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -2,7 +2,7 @@ import { unzlib } from 'https://deno.land/x/denoflate@1.1/mod.ts' import { Client } from '../models/client.ts' import { DISCORD_GATEWAY_URL, - DISCORD_API_VERSION + DISCORD_API_VERSION, } from '../consts/urlsAndVersions.ts' import { GatewayResponse } from '../types/gatewayResponse.ts' import { @@ -10,18 +10,23 @@ import { GatewayIntents, GatewayCloseCodes, IdentityPayload, - StatusUpdatePayload + StatusUpdatePayload, } 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' +export interface RequestMembersOptions { + limit?: number + presences?: boolean + query?: string + users?: string[] +} + /** * Handles Discord gateway connection. * You should not use this and rather use Client class. - * - * @beta */ class Gateway { websocket: WebSocket @@ -38,7 +43,7 @@ class Gateway { client: Client cache: GatewayCache - constructor (client: Client, token: string, intents: GatewayIntents[]) { + constructor(client: Client, token: string, intents: GatewayIntents[]) { this.token = token this.intents = intents this.client = client @@ -55,12 +60,12 @@ class Gateway { this.websocket.onerror = this.onerror.bind(this) } - private onopen (): void { + private onopen(): void { this.connected = true this.debug('Connected to Gateway!') } - private async onmessage (event: MessageEvent): Promise { + private async onmessage(event: MessageEvent): Promise { let data = event.data if (data instanceof ArrayBuffer) { data = new Uint8Array(data) @@ -144,7 +149,7 @@ class Gateway { } } - private async onclose (event: CloseEvent): Promise { + private async onclose(event: CloseEvent): Promise { this.debug(`Connection Closed with code: ${event.code}`) if (event.code === GatewayCloseCodes.UNKNOWN_ERROR) { @@ -191,12 +196,12 @@ class Gateway { } } - private onerror (event: Event | ErrorEvent): void { + private onerror(event: Event | ErrorEvent): void { const eventError = event as ErrorEvent console.log(eventError) } - private async sendIdentify (forceNewSession?: boolean): Promise { + private async sendIdentify(forceNewSession?: boolean): Promise { if (this.client.bot === true) { this.debug('Fetching /gateway/bot...') const info = await this.client.rest.get(GATEWAY_BOT()) @@ -225,7 +230,7 @@ class Gateway { properties: { $os: Deno.build.os, $browser: 'harmony', - $device: 'harmony' + $device: 'harmony', }, compress: true, shard: [0, 1], // TODO: Make sharding possible @@ -233,7 +238,7 @@ class Gateway { (previous, current) => previous | current, 0 ), - presence: this.client.presence.create() + presence: this.client.presence.create(), } if (this.client.bot === false) { @@ -245,17 +250,17 @@ class Gateway { $browser: 'Firefox', $device: '', $referrer: '', - $referring_domain: '' + $referring_domain: '', } } this.send({ op: GatewayOpcodes.IDENTIFY, - d: payload + d: payload, }) } - private async sendResume (): Promise { + private async sendResume(): Promise { this.debug(`Preparing to resume with Session: ${this.sessionID}`) if (this.sequenceID === undefined) { const cached = await this.cache.get('seq') @@ -267,17 +272,37 @@ class Gateway { d: { token: this.token, session_id: this.sessionID, - seq: this.sequenceID ?? null - } + seq: this.sequenceID ?? null, + }, } this.send(resumePayload) } - debug (msg: string): void { + requestMembers(guild: string, options: RequestMembersOptions = {}): string { + if (options.query !== undefined && options.limit === undefined) + throw new Error( + 'Missing limit property when specifying query for Requesting Members!' + ) + const nonce = `${guild}_${new Date().getTime()}` + this.send({ + op: GatewayOpcodes.REQUEST_GUILD_MEMBERS, + d: { + guild_id: guild, + query: options.query, + limit: options.limit, + presences: options.presences, + user_ids: options.users, + nonce, + }, + }) + return nonce + } + + debug(msg: string): void { this.client.debug('Gateway', msg) } - async reconnect (forceNew?: boolean): Promise { + async reconnect(forceNew?: boolean): Promise { clearInterval(this.heartbeatIntervalID) if (forceNew === undefined || !forceNew) await this.cache.delete('session_id') @@ -285,7 +310,7 @@ class Gateway { this.initWebsocket() } - initWebsocket (): void { + initWebsocket(): void { this.websocket = new WebSocket( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `${DISCORD_GATEWAY_URL}/?v=${DISCORD_API_VERSION}&encoding=json`, @@ -298,41 +323,41 @@ class Gateway { this.websocket.onerror = this.onerror.bind(this) } - close (): void { + close(): void { this.websocket.close(1000) } - send (data: GatewayResponse): boolean { + send(data: GatewayResponse): boolean { if (this.websocket.readyState !== this.websocket.OPEN) return false this.websocket.send( JSON.stringify({ op: data.op, d: data.d, s: typeof data.s === 'number' ? data.s : null, - t: data.t === undefined ? null : data.t + t: data.t === undefined ? null : data.t, }) ) return true } - sendPresence (data: StatusUpdatePayload): void { + sendPresence(data: StatusUpdatePayload): void { this.send({ op: GatewayOpcodes.PRESENCE_UPDATE, - d: data + d: data, }) } - sendHeartbeat (): void { + sendHeartbeat(): void { const payload = { op: GatewayOpcodes.HEARTBEAT, - d: this.sequenceID ?? null + d: this.sequenceID ?? null, } this.send(payload) this.lastPingTimestamp = Date.now() } - heartbeat (): void { + heartbeat(): void { if (this.heartbeatServerResponded) { this.heartbeatServerResponded = false } else { diff --git a/src/managers/guildVoiceStates.ts b/src/managers/guildVoiceStates.ts new file mode 100644 index 0000000..70d0a07 --- /dev/null +++ b/src/managers/guildVoiceStates.ts @@ -0,0 +1,30 @@ +import { Client } from '../models/client.ts' +import { Guild } from "../structures/guild.ts" +import { VoiceChannel } from "../structures/guildVoiceChannel.ts" +import { User } from "../structures/user.ts" +import { VoiceState } from "../structures/voiceState.ts" +import { VoiceStatePayload } from "../types/voice.ts" +import { BaseManager } from './base.ts' + +export class GuildVoiceStatesManager extends BaseManager { + guild: Guild + + async get (key: string): Promise { + const raw = await this._get(key) + if (raw === undefined) return + + const guild = raw.guild_id === undefined ? undefined : await this.client.guilds.get(raw.guild_id) + + return new VoiceState(this.client, raw, { + user: (await this.client.users.get(raw.user_id) as unknown) as User, + channel: raw.channel_id == null ? null : (await this.client.channels.get(raw.channel_id) as unknown) as VoiceChannel, + guild, + member: guild === undefined ? undefined : await guild.members.get(raw.user_id) + }) + } + + constructor (client: Client, guild: Guild) { + super(client, `vs:${guild.id}`, VoiceState) + this.guild = guild + } +} diff --git a/src/managers/memberRoles.ts b/src/managers/memberRoles.ts index 87cf9db..b1aabec 100644 --- a/src/managers/memberRoles.ts +++ b/src/managers/memberRoles.ts @@ -1,42 +1,81 @@ -import { Client } from '../models/client.ts' -import { BaseChildManager } from './baseChild.ts' -import { RolePayload } from "../types/role.ts" -import { Role } from "../structures/role.ts" -import { Member } from "../structures/member.ts" -import { RolesManager } from "./roles.ts" -import { MemberPayload } from "../types/guild.ts" - -export class MemberRolesManager extends BaseChildManager< - RolePayload, - Role -> { - member: Member - - constructor (client: Client, parent: RolesManager, member: Member) { - super(client, parent as any) - this.member = member - } - - async get (id: string): Promise { - const res = await this.parent.get(id) - const mem = await (this.parent as any).guild.members._get(this.member.id) as MemberPayload - if (res !== undefined && (mem.roles.includes(res.id) === true || res.id === this.member.guild.id)) return res - else return undefined - } - - async array (): Promise { - const arr = (await this.parent.array()) as Role[] - const mem = await (this.parent as any).guild.members._get(this.member.id) as MemberPayload - return arr.filter( - (c: any) => mem.roles.includes(c.id) as boolean || c.id === this.member.guild.id - ) as any - } - - async flush (): Promise { - const arr = await this.array() - for (const elem of arr) { - this.parent.delete(elem.id) - } - return true - } -} +import { Client } from '../models/client.ts' +import { BaseChildManager } from './baseChild.ts' +import { RolePayload } from '../types/role.ts' +import { Role } from '../structures/role.ts' +import { Member } from '../structures/member.ts' +import { RolesManager } from './roles.ts' +import { MemberPayload } from '../types/guild.ts' +import { GUILD_MEMBER_ROLE } from '../types/endpoint.ts' + +export class MemberRolesManager extends BaseChildManager { + member: Member + + constructor(client: Client, parent: RolesManager, member: Member) { + super(client, parent as any) + this.member = member + } + + async get(id: string): Promise { + const res = await this.parent.get(id) + const mem = (await (this.parent as any).guild.members._get( + this.member.id + )) as MemberPayload + if ( + res !== undefined && + (mem.roles.includes(res.id) === true || res.id === this.member.guild.id) + ) + return res + else return undefined + } + + async array(): Promise { + const arr = (await this.parent.array()) as Role[] + const mem = (await (this.parent as any).guild.members._get( + this.member.id + )) as MemberPayload + return arr.filter( + (c: any) => + (mem.roles.includes(c.id) as boolean) || c.id === this.member.guild.id + ) as any + } + + async flush(): Promise { + const arr = await this.array() + for (const elem of arr) { + this.parent.delete(elem.id) + } + return true + } + + async add(role: string | Role): Promise { + const res = await this.client.rest.put( + GUILD_MEMBER_ROLE( + this.member.guild.id, + this.member.id, + typeof role === 'string' ? role : role.id + ), + undefined, + undefined, + undefined, + true + ) + + return res.status === 204 + } + + async remove(role: string | Role): Promise { + const res = await this.client.rest.delete( + GUILD_MEMBER_ROLE( + this.member.guild.id, + this.member.id, + typeof role === 'string' ? role : role.id + ), + undefined, + undefined, + undefined, + true + ) + + return res.status === 204 + } +} diff --git a/src/models/command.ts b/src/models/command.ts index 3d6d207..1c99110 100644 --- a/src/models/command.ts +++ b/src/models/command.ts @@ -1,190 +1,190 @@ -import { Guild } from '../structures/guild.ts' -import { Message } from '../structures/message.ts' -import { TextChannel } from '../structures/textChannel.ts' -import { User } from '../structures/user.ts' -import { Collection } from '../utils/collection.ts' -import { CommandClient } from './commandClient.ts' -import { Extension } from "./extensions.ts" - -export interface CommandContext { - /** The Client object */ - client: CommandClient - /** Message which was parsed for Command */ - message: Message - /** The Author of the Message */ - author: User - /** The Channel in which Command was used */ - channel: TextChannel - /** Prefix which was used */ - prefix: string - /** Oject of Command which was used */ - command: Command - /** Name of Command which was used */ - name: string - /** Array of Arguments used with Command */ - args: string[] - /** Complete Raw String of Arguments */ - argString: string - /** Guild which the command has called */ - guild?: Guild -} - -export class Command { - /** Name of the Command */ - name: string = '' - /** Description of the Command */ - description?: string - /** Array of Aliases of Command, or only string */ - aliases?: string | string[] - /** Extension (Parent) of the Command */ - extension?: Extension - /** Usage of Command, only Argument Names */ - usage?: string | string[] - /** Usage Example of Command, only Arguments (without Prefix and Name) */ - examples?: string | string[] - /** Does the Command take Arguments? Maybe number of required arguments? */ - args?: number | boolean - /** Permission(s) required for using Command */ - permissions?: string | string[] - /** Permission(s) bot will need in order to execute Command */ - botPermissions?: string | string[] - /** Role(s) user will require in order to use Command. List or one of ID or name */ - roles?: string | string[] - /** Whitelisted Guilds. Only these Guild(s) can execute Command. (List or one of IDs) */ - whitelistedGuilds?: string | string[] - /** Whitelisted Channels. Command can be executed only in these channels. (List or one of IDs) */ - whitelistedChannels?: string | string[] - /** Whitelisted Users. Command can be executed only by these Users (List or one of IDs) */ - whitelistedUsers?: string | string[] - /** Whether the Command can only be used in Guild (if allowed in DMs) */ - guildOnly?: boolean - /** Whether the Command can only be used in Bot's DMs (if allowed) */ - dmOnly?: boolean - /** Whether the Command can only be used by Bot Owners */ - ownerOnly?: boolean - - /** Method executed before executing actual command. Returns bool value - whether to continue or not (optional) */ - beforeExecute(ctx: CommandContext): boolean | Promise { return true } - /** Actual command code, which is executed when all checks have passed. */ - execute(ctx: CommandContext): any { } - /** Method executed after executing command, passes on CommandContext and the value returned by execute too. (optional) */ - afterExecute(ctx: CommandContext, executeResult: any): any { } - - toString(): string { - return `Command: ${this.name}${this.extension !== undefined ? ` [${this.extension.name}]` : ''}` - } -} - -export class CommandsManager { - client: CommandClient - list: Collection = new Collection() - disabled: Set = new Set() - - constructor(client: CommandClient) { - this.client = client - } - - /** Number of loaded Commands */ - get count(): number { return this.list.size } - - /** Find a Command by name/alias */ - find(search: string): Command | undefined { - if (this.client.caseSensitive === false) search = search.toLowerCase() - return this.list.find((cmd: Command): boolean => { - const name = - this.client.caseSensitive === true ? cmd.name : cmd.name.toLowerCase() - if (name === search) return true - else if (cmd.aliases !== undefined) { - let aliases: string[] - if (typeof cmd.aliases === 'string') aliases = [cmd.aliases] - else aliases = cmd.aliases - if (this.client.caseSensitive === false) - aliases = aliases.map(e => e.toLowerCase()) - return aliases.includes(search) - } else return false - }) - } - - /** Fetch a Command including disable checks */ - fetch(name: string, bypassDisable?: boolean): Command | undefined { - const cmd = this.find(name) - if (cmd === undefined) return - if (this.isDisabled(cmd) && bypassDisable !== true) return - return cmd - } - - /** Check whether a Command exists or not */ - exists(search: Command | string): boolean { - let exists = false - if (typeof search === 'string') return this.find(search) !== undefined - else { - exists = this.find(search.name) !== undefined - if (search.aliases !== undefined) { - const aliases: string[] = - typeof search.aliases === 'string' ? [search.aliases] : search.aliases - exists = - aliases.map(alias => this.find(alias) !== undefined).find(e => e) ?? - false - } - return exists - } - } - - /** Add a Command */ - add(cmd: Command | typeof Command): boolean { - // eslint-disable-next-line new-cap - if (!(cmd instanceof Command)) cmd = new cmd() - if (this.exists(cmd)) throw new Error(`Failed to add Command '${cmd.toString()}' with name/alias already exists.`) - this.list.set(cmd.name, cmd) - return true - } - - /** Delete a Command */ - delete(cmd: string | Command): boolean { - const find = typeof cmd === 'string' ? this.find(cmd) : cmd - if (find === undefined) return false - else return this.list.delete(find.name) - } - - /** Check whether a Command is disabled or not */ - isDisabled(name: string | Command): boolean { - const cmd = typeof name === "string" ? this.find(name) : name - if (cmd === undefined) return false - const exists = this.exists(name) - if (!exists) return false - return this.disabled.has(cmd.name) - } - - /** Disable a Command */ - disable(name: string | Command): boolean { - const cmd = typeof name === "string" ? this.find(name) : name - if (cmd === undefined) return false - if (this.isDisabled(cmd)) return false - this.disabled.add(cmd.name) - return true - } -} - -export interface ParsedCommand { - name: string - args: string[] - argString: string -} - -export const parseCommand = ( - client: CommandClient, - msg: Message, - prefix: string -): ParsedCommand => { - let content = msg.content.slice(prefix.length) - if (client.spacesAfterPrefix === true) content = content.trim() - const args = content.split(client.betterArgs === true ? /[\S\s]*/ : / +/) - const name = args.shift() as string - const argString = content.slice(name.length).trim() - - return { - name, - args, - argString - } -} +import { Guild } from '../structures/guild.ts' +import { Message } from '../structures/message.ts' +import { TextChannel } from '../structures/textChannel.ts' +import { User } from '../structures/user.ts' +import { Collection } from '../utils/collection.ts' +import { CommandClient } from './commandClient.ts' +import { Extension } from "./extensions.ts" + +export interface CommandContext { + /** The Client object */ + client: CommandClient + /** Message which was parsed for Command */ + message: Message + /** The Author of the Message */ + author: User + /** The Channel in which Command was used */ + channel: TextChannel + /** Prefix which was used */ + prefix: string + /** Oject of Command which was used */ + command: Command + /** Name of Command which was used */ + name: string + /** Array of Arguments used with Command */ + args: string[] + /** Complete Raw String of Arguments */ + argString: string + /** Guild which the command has called */ + guild?: Guild +} + +export class Command { + /** Name of the Command */ + name: string = '' + /** Description of the Command */ + description?: string + /** Array of Aliases of Command, or only string */ + aliases?: string | string[] + /** Extension (Parent) of the Command */ + extension?: Extension + /** Usage of Command, only Argument Names */ + usage?: string | string[] + /** Usage Example of Command, only Arguments (without Prefix and Name) */ + examples?: string | string[] + /** Does the Command take Arguments? Maybe number of required arguments? */ + args?: number | boolean + /** Permission(s) required for using Command */ + permissions?: string | string[] + /** Permission(s) bot will need in order to execute Command */ + botPermissions?: string | string[] + /** Role(s) user will require in order to use Command. List or one of ID or name */ + roles?: string | string[] + /** Whitelisted Guilds. Only these Guild(s) can execute Command. (List or one of IDs) */ + whitelistedGuilds?: string | string[] + /** Whitelisted Channels. Command can be executed only in these channels. (List or one of IDs) */ + whitelistedChannels?: string | string[] + /** Whitelisted Users. Command can be executed only by these Users (List or one of IDs) */ + whitelistedUsers?: string | string[] + /** Whether the Command can only be used in Guild (if allowed in DMs) */ + guildOnly?: boolean + /** Whether the Command can only be used in Bot's DMs (if allowed) */ + dmOnly?: boolean + /** Whether the Command can only be used by Bot Owners */ + ownerOnly?: boolean + + /** Method executed before executing actual command. Returns bool value - whether to continue or not (optional) */ + beforeExecute(ctx: CommandContext): boolean | Promise { return true } + /** Actual command code, which is executed when all checks have passed. */ + execute(ctx: CommandContext): any { } + /** Method executed after executing command, passes on CommandContext and the value returned by execute too. (optional) */ + afterExecute(ctx: CommandContext, executeResult: any): any { } + + toString(): string { + return `Command: ${this.name}${this.extension !== undefined ? ` [${this.extension.name}]` : ''}` + } +} + +export class CommandsManager { + client: CommandClient + list: Collection = new Collection() + disabled: Set = new Set() + + constructor(client: CommandClient) { + this.client = client + } + + /** Number of loaded Commands */ + get count(): number { return this.list.size } + + /** Find a Command by name/alias */ + find(search: string): Command | undefined { + if (this.client.caseSensitive === false) search = search.toLowerCase() + return this.list.find((cmd: Command): boolean => { + const name = + this.client.caseSensitive === true ? cmd.name : cmd.name.toLowerCase() + if (name === search) return true + else if (cmd.aliases !== undefined) { + let aliases: string[] + if (typeof cmd.aliases === 'string') aliases = [cmd.aliases] + else aliases = cmd.aliases + if (this.client.caseSensitive === false) + aliases = aliases.map(e => e.toLowerCase()) + return aliases.includes(search) + } else return false + }) + } + + /** Fetch a Command including disable checks */ + fetch(name: string, bypassDisable?: boolean): Command | undefined { + const cmd = this.find(name) + if (cmd === undefined) return + if (this.isDisabled(cmd) && bypassDisable !== true) return + return cmd + } + + /** Check whether a Command exists or not */ + exists(search: Command | string): boolean { + let exists = false + if (typeof search === 'string') return this.find(search) !== undefined + else { + exists = this.find(search.name) !== undefined + if (search.aliases !== undefined) { + const aliases: string[] = + typeof search.aliases === 'string' ? [search.aliases] : search.aliases + exists = + aliases.map(alias => this.find(alias) !== undefined).find(e => e) ?? + false + } + return exists + } + } + + /** Add a Command */ + add(cmd: Command | typeof Command): boolean { + // eslint-disable-next-line new-cap + if (!(cmd instanceof Command)) cmd = new cmd() + if (this.exists(cmd)) throw new Error(`Failed to add Command '${cmd.toString()}' with name/alias already exists.`) + this.list.set(cmd.name, cmd) + return true + } + + /** Delete a Command */ + delete(cmd: string | Command): boolean { + const find = typeof cmd === 'string' ? this.find(cmd) : cmd + if (find === undefined) return false + else return this.list.delete(find.name) + } + + /** Check whether a Command is disabled or not */ + isDisabled(name: string | Command): boolean { + const cmd = typeof name === "string" ? this.find(name) : name + if (cmd === undefined) return false + const exists = this.exists(name) + if (!exists) return false + return this.disabled.has(cmd.name) + } + + /** Disable a Command */ + disable(name: string | Command): boolean { + const cmd = typeof name === "string" ? this.find(name) : name + if (cmd === undefined) return false + if (this.isDisabled(cmd)) return false + this.disabled.add(cmd.name) + return true + } +} + +export interface ParsedCommand { + name: string + args: string[] + argString: string +} + +export const parseCommand = ( + client: CommandClient, + msg: Message, + prefix: string +): ParsedCommand => { + let content = msg.content.slice(prefix.length) + if (client.spacesAfterPrefix === true) content = content.trim() + const args = content.split(client.betterArgs === true ? /[\S\s]*/ : / +/) + const name = args.shift() as string + const argString = content.slice(name.length).trim() + + return { + name, + args, + argString + } +} diff --git a/src/models/rest.ts b/src/models/rest.ts index 3dfc0e8..bc265c5 100644 --- a/src/models/rest.ts +++ b/src/models/rest.ts @@ -3,7 +3,13 @@ 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 type RequestMethods = + | 'get' + | 'post' + | 'put' + | 'patch' + | 'head' + | 'delete' export enum HttpResponseCode { Ok = 200, @@ -16,7 +22,7 @@ export enum HttpResponseCode { NotFound = 404, MethodNotAllowed = 405, TooManyRequests = 429, - GatewayUnavailable = 502 + GatewayUnavailable = 502, } export interface RequestHeaders { @@ -30,11 +36,14 @@ export class DiscordAPIError extends Error { export interface QueuedItem { bucket?: string | null url: string - onComplete: () => Promise<{ - rateLimited: any - bucket?: string | null - before: boolean - } | undefined> + onComplete: () => Promise< + | { + rateLimited: any + bucket?: string | null + before: boolean + } + | undefined + > } export interface RateLimit { @@ -85,16 +94,14 @@ export class RESTManager { async processQueue(): Promise { if (Object.keys(this.queues).length !== 0 && !this.globalRateLimit) { await Promise.allSettled( - Object.values(this.queues).map(async pathQueue => { + Object.values(this.queues).map(async (pathQueue) => { const request = pathQueue.shift() if (request === undefined) return const rateLimitedURLResetIn = await this.isRateLimited(request.url) if (typeof request.bucket === 'string') { - const rateLimitResetIn = await this.isRateLimited( - request.bucket - ) + const rateLimitResetIn = await this.isRateLimited(request.bucket) if (rateLimitResetIn !== false) { this.queue(request) } else { @@ -102,7 +109,7 @@ export class RESTManager { if (result?.rateLimited !== undefined) { this.queue({ ...request, - bucket: result.bucket ?? request.bucket + bucket: result.bucket ?? request.bucket, }) } } @@ -114,7 +121,7 @@ export class RESTManager { if (result?.rateLimited !== undefined) { this.queue({ ...request, - bucket: result.bucket ?? request.bucket + bucket: result.bucket ?? request.bucket, }) } } @@ -132,20 +139,18 @@ export class RESTManager { } else this.processing = false } - prepare( - body: any, - method: RequestMethods - ): { [key: string]: any } { - + prepare(body: any, method: RequestMethods): { [key: string]: any } { const headers: RequestHeaders = { - 'User-Agent': `DiscordBot (harmony, https://github.com/harmony-org/harmony)` + 'User-Agent': `DiscordBot (harmony, https://github.com/harmony-org/harmony)`, } - if (this.client !== undefined) headers.Authorization = `Bot ${this.client.token}` + if (this.client !== undefined) + headers.Authorization = `Bot ${this.client.token}` if (this.client?.token === undefined) delete headers.Authorization - if (method === 'get' || method === 'head' || method === 'delete') body = undefined + if (method === 'get' || method === 'head' || method === 'delete') + body = undefined if (body?.reason !== undefined) { headers['X-Audit-Log-Reason'] = encodeURIComponent(body.reason) @@ -163,7 +168,7 @@ export class RESTManager { const data: { [name: string]: any } = { headers, body: body?.file ?? JSON.stringify(body), - method: method.toUpperCase() + method: method.toUpperCase(), } if (this.client?.bot === false) { @@ -217,14 +222,14 @@ export class RESTManager { this.rateLimits.set(url, { url, resetAt: Number(resetAt) * 1000, - bucket + bucket, }) if (bucket !== null) { this.rateLimits.set(bucket, { url, resetAt: Number(resetAt) * 1000, - bucket + bucket, }) } } @@ -237,14 +242,14 @@ export class RESTManager { this.rateLimits.set('global', { url: 'global', resetAt: reset, - bucket + bucket, }) if (bucket !== null) { this.rateLimits.set(bucket, { url: 'global', resetAt: reset, - bucket + bucket, }) } } @@ -253,32 +258,46 @@ export class RESTManager { } async handleStatusCode( - response: Response, body: any, data: { [key: string]: any } + response: Response, + body: any, + data: { [key: string]: any } ): Promise { const status = response.status if ( - (status >= 200 && status < 400) - || status === HttpResponseCode.NoContent - || status === HttpResponseCode.TooManyRequests - ) return + (status >= 200 && status < 400) || + status === HttpResponseCode.NoContent || + status === HttpResponseCode.TooManyRequests + ) + return - let text: undefined | string = Deno.inspect(body.errors === undefined ? body : body.errors) + let text: undefined | string = Deno.inspect( + body.errors === undefined ? body : body.errors + ) if (text === 'undefined') text = undefined if (status === HttpResponseCode.Unauthorized) - throw new DiscordAPIError(`Request was not successful (Unauthorized). Invalid Token.\n${text}`) + throw new DiscordAPIError( + `Request was not successful (Unauthorized). Invalid Token.\n${text}` + ) // At this point we know it is error - let error = { url: response.url, status, method: data.method, body: data.body } + let error = { + url: response.url, + status, + method: data.method, + body: data.body, + } if (body !== undefined) error = Object.assign(error, body) - if ([ - HttpResponseCode.BadRequest, - HttpResponseCode.NotFound, - HttpResponseCode.Forbidden, - HttpResponseCode.MethodNotAllowed - ].includes(status)) { + if ( + [ + HttpResponseCode.BadRequest, + HttpResponseCode.NotFound, + HttpResponseCode.Forbidden, + HttpResponseCode.MethodNotAllowed, + ].includes(status) + ) { throw new DiscordAPIError(Deno.inspect(error)) } else if (status === HttpResponseCode.GatewayUnavailable) { throw new DiscordAPIError(Deno.inspect(error)) @@ -291,7 +310,7 @@ export class RESTManager { body?: unknown, maxRetries = 0, bucket?: string | null, - rawResponse?: boolean, + rawResponse?: boolean ): Promise { return await new Promise((resolve, reject) => { const onComplete = async (): Promise => { @@ -301,20 +320,20 @@ export class RESTManager { return { rateLimited: rateLimitResetIn, before: true, - bucket + bucket, } } const query = method === 'get' && body !== undefined ? Object.entries(body as any) - .map( - ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent( - value as any - )}` - ) - .join('&') + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent( + value as any + )}` + ) + .join('&') : '' let urlToUse = method === 'get' && query !== '' ? `${url}?${query}` : url @@ -329,7 +348,10 @@ export class RESTManager { const response = await fetch(urlToUse, requestData) const bucketFromHeaders = this.processHeaders(url, response.headers) - if (response.status === 204) return resolve(rawResponse === true ? { response, body: null } : undefined) + if (response.status === 204) + return resolve( + rawResponse === true ? { response, body: null } : undefined + ) const json: any = await response.json() await this.handleStatusCode(response, json, requestData) @@ -345,7 +367,7 @@ export class RESTManager { return { rateLimited: json.retry_after, before: false, - bucket: bucketFromHeaders + bucket: bucketFromHeaders, } } return resolve(rawResponse === true ? { response, body: json } : json) @@ -357,7 +379,7 @@ export class RESTManager { this.queue({ onComplete, bucket, - url + url, }) if (!this.processing) { this.processing = true @@ -376,23 +398,53 @@ export class RESTManager { }) } - async get(url: string, body?: unknown, maxRetries = 0, bucket?: string | null, rawResponse?: boolean): Promise { + async get( + url: string, + body?: unknown, + maxRetries = 0, + bucket?: string | null, + rawResponse?: boolean + ): Promise { return await this.make('get', url, body, maxRetries, bucket, rawResponse) } - async post(url: string, body?: unknown, maxRetries = 0, bucket?: string | null, rawResponse?: boolean): Promise { + async post( + url: string, + body?: unknown, + maxRetries = 0, + bucket?: string | null, + rawResponse?: boolean + ): Promise { return await this.make('post', url, body, maxRetries, bucket, rawResponse) } - async delete(url: string, body?: unknown, maxRetries = 0, bucket?: string | null, rawResponse?: boolean): Promise { + async delete( + url: string, + body?: unknown, + maxRetries = 0, + bucket?: string | null, + rawResponse?: boolean + ): Promise { return await this.make('delete', url, body, maxRetries, bucket, rawResponse) } - async patch(url: string, body?: unknown, maxRetries = 0, bucket?: string | null, rawResponse?: boolean): Promise { + async patch( + url: string, + body?: unknown, + maxRetries = 0, + bucket?: string | null, + rawResponse?: boolean + ): Promise { return await this.make('patch', url, body, maxRetries, bucket, rawResponse) } - async put(url: string, body?: unknown, maxRetries = 0, bucket?: string | null, rawResponse?: boolean): Promise { + async put( + url: string, + body?: unknown, + maxRetries = 0, + bucket?: string | null, + rawResponse?: boolean + ): Promise { return await this.make('put', url, body, maxRetries, bucket, rawResponse) } } diff --git a/src/models/voice.ts b/src/models/voice.ts index b4c02be..80b51ff 100644 --- a/src/models/voice.ts +++ b/src/models/voice.ts @@ -1,10 +1,26 @@ +import { Guild } from "../structures/guild.ts" +import { VoiceChannel } from "../structures/guildVoiceChannel.ts" import { Client } from './client.ts' +export interface VoiceOptions { + guild: Guild, + channel: VoiceChannel +} + export class VoiceClient { client: Client + ws?: WebSocket + guild: Guild + channel: VoiceChannel - constructor(client: Client) { + constructor(client: Client, options: VoiceOptions) { this.client = client - + this.guild = options.guild + this.channel = options.channel + } + + async connect(): Promise { + // TODO(DjDeveloperr): Actually understand what the hell docs say + return this } } \ No newline at end of file diff --git a/src/structures/guild.ts b/src/structures/guild.ts index c8416b8..0a5c4c9 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -1,14 +1,14 @@ import { Client } from '../models/client.ts' import { + GuildBanPayload, GuildFeatures, GuildIntegrationPayload, GuildPayload, IntegrationAccountPayload, - IntegrationExpireBehavior + IntegrationExpireBehavior, } from '../types/guild.ts' import { PresenceUpdatePayload } from '../types/gateway.ts' import { Base } from './base.ts' -import { VoiceState } from './voiceState.ts' import { RolesManager } from '../managers/roles.ts' import { GuildChannelsManager } from '../managers/guildChannels.ts' import { MembersManager } from '../managers/members.ts' @@ -17,7 +17,100 @@ import { GuildEmojisManager } from '../managers/guildEmojis.ts' import { Member } from './member.ts' import { User } from './user.ts' import { Application } from './application.ts' -import { GUILD_INTEGRATIONS } from '../types/endpoint.ts' +import { GUILD_BAN, GUILD_BANS, GUILD_INTEGRATIONS } from '../types/endpoint.ts' +import { GuildVoiceStatesManager } from '../managers/guildVoiceStates.ts' +import { RequestMembersOptions } from '../gateway/index.ts' + +export class GuildBan extends Base { + guild: Guild + reason?: string + user: User + + constructor(client: Client, data: GuildBanPayload, guild: Guild) { + super(client, data) + this.guild = guild + this.reason = data.reason === null ? undefined : data.reason + this.user = new User(client, data.user) + } +} + +export class GuildBans { + client: Client + guild: Guild + + constructor(client: Client, guild: Guild) { + this.client = client + this.guild = guild + } + + /** + * Get all bans in the Guild. + */ + async all(): Promise { + const res = await this.client.rest.get(GUILD_BANS(this.guild.id)) + if (typeof res !== 'object' || !Array.isArray(res)) + throw new Error('Failed to fetch Guild Bans') + + const bans = (res as GuildBanPayload[]).map( + (ban) => new GuildBan(this.client, ban, this.guild) + ) + return bans + } + + /** + * Get ban details of a User if any. + * @param user User to get ban of, ID or User object. + */ + async get(user: string | User): Promise { + const res = await this.client.rest.get( + GUILD_BAN(this.guild.id, typeof user === 'string' ? user : user.id) + ) + if (typeof res !== 'object') throw new Error('Failed to fetch Guild Ban') + return new GuildBan(this.client, res, this.guild) + } + + /** + * Ban a User. + * @param user User to ban, ID or User object. + * @param reason Reason for the Ban. + * @param deleteMessagesDays Delete Old Messages? If yes, how much days. + */ + async add( + user: string | User, + reason?: string, + deleteMessagesDays?: number + ): Promise { + const res = await this.client.rest.put( + GUILD_BAN(this.guild.id, typeof user === 'string' ? user : user.id), + { + reason, + delete_message_days: deleteMessagesDays, + }, + undefined, + null, + true + ) + + if (res.status !== 204) throw new Error('Failed to Add Guild Ban') + } + + /** + * Unban (remove ban from) a User. + * @param user User to unban, ID or User object. + */ + async remove(user: string | User): Promise { + const res = await this.client.rest.delete( + GUILD_BAN(this.guild.id, typeof user === 'string' ? user : user.id), + undefined, + undefined, + null, + true + ) + + if (res.status !== 204) return false + else return true + } +} export class Guild extends Base { id: string @@ -49,7 +142,7 @@ export class Guild extends Base { large?: boolean unavailable: boolean memberCount?: number - voiceStates?: VoiceState[] + voiceStates: GuildVoiceStatesManager members: MembersManager channels: GuildChannelsManager presences?: PresenceUpdatePayload[] @@ -65,12 +158,15 @@ export class Guild extends Base { maxVideoChannelUsers?: number approximateNumberCount?: number approximatePresenceCount?: number + bans: GuildBans - constructor (client: Client, data: GuildPayload) { + constructor(client: Client, data: GuildPayload) { super(client, data) this.id = data.id + this.bans = new GuildBans(client, this) this.unavailable = data.unavailable this.members = new MembersManager(this.client, this) + this.voiceStates = new GuildVoiceStatesManager(client, this) this.channels = new GuildChannelsManager( this.client, this.client.channels, @@ -96,15 +192,6 @@ export class Guild extends Base { this.verificationLevel = data.verification_level this.defaultMessageNotifications = data.default_message_notifications this.explicitContentFilter = data.explicit_content_filter - // this.roles = data.roles.map( - // v => cache.get('role', v.id) ?? new Role(client, v) - // ) - // data.roles.forEach(role => { - // this.roles.set(role.id, new Role(client, role)) - // }) - // this.emojis = data.emojis.map( - // v => cache.get('emoji', v.id) ?? new Emoji(client, v) - // ) this.features = data.features this.mfaLevel = data.mfa_level this.systemChannelID = data.system_channel_id @@ -113,20 +200,6 @@ export class Guild extends Base { this.joinedAt = data.joined_at this.large = data.large this.memberCount = data.member_count - // TODO: Cache in Gateway Event code - // this.voiceStates = data.voice_states?.map( - // v => - // cache.get('voiceState', `${v.guild_id}:${v.user_id}`) ?? - // new VoiceState(client, v) - // ) - // this.members = data.members?.map( - // v => - // cache.get('member', `${this.id}:${v.user.id}`) ?? - // new Member(client, v) - // ) - // this.channels = data.channels?.map( - // v => cache.get('channel', v.id) ?? getChannelByType(this.client, v) - // ) this.presences = data.presences this.maxPresences = data.max_presences this.maxMembers = data.max_members @@ -143,7 +216,7 @@ export class Guild extends Base { } } - protected readFromData (data: GuildPayload): void { + protected readFromData(data: GuildPayload): void { super.readFromData(data) this.id = data.id ?? this.id this.unavailable = data.unavailable ?? this.unavailable @@ -167,14 +240,6 @@ export class Guild extends Base { data.default_message_notifications ?? this.defaultMessageNotifications this.explicitContentFilter = data.explicit_content_filter ?? this.explicitContentFilter - // this.roles = - // data.roles.map( - // v => cache.get('role', v.id) ?? new Role(this.client, v) - // ) ?? this.roles - // this.emojis = - // data.emojis.map( - // v => cache.get('emoji', v.id) ?? new Emoji(this.client, v) - // ) ?? this.emojis this.features = data.features ?? this.features this.mfaLevel = data.mfa_level ?? this.mfaLevel this.systemChannelID = data.system_channel_id ?? this.systemChannelID @@ -184,22 +249,6 @@ export class Guild extends Base { this.joinedAt = data.joined_at ?? this.joinedAt this.large = data.large ?? this.large this.memberCount = data.member_count ?? this.memberCount - // this.voiceStates = - // data.voice_states?.map( - // v => - // cache.get('voiceState', `${v.guild_id}:${v.user_id}`) ?? - // new VoiceState(this.client, v) - // ) ?? this.voiceStates - // this.members = - // data.members?.map( - // v => - // cache.get('member', `${this.id}:${v.user.id}`) ?? - // new Member(this.client, v) - // ) ?? this.members - // this.channels = - // data.channels?.map( - // v => cache.get('channel', v.id) ?? getChannelByType(this.client, v, this) - // ) ?? this.members this.presences = data.presences ?? this.presences this.maxPresences = data.max_presences ?? this.maxPresences this.maxMembers = data.max_members ?? this.maxMembers @@ -221,22 +270,65 @@ export class Guild extends Base { } } - async getEveryoneRole (): Promise { + /** + * Get Everyone role of the Guild + */ + async getEveryoneRole(): Promise { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return (await this.roles.get(this.id)) as Role } - async me (): Promise { + /** + * Get current client's member in the Guild + */ + async me(): Promise { const get = await this.members.get(this.client.user?.id as string) if (get === undefined) throw new Error('Guild#me is not cached') return get } - async fetchIntegrations (): Promise { + /** + * Fetch Guild's Integrations (Webhooks, Bots, etc.) + */ + async fetchIntegrations(): Promise { const raw = (await this.client.rest.get( GUILD_INTEGRATIONS(this.id) )) as GuildIntegrationPayload[] - return raw.map(e => new GuildIntegration(this.client, e)) + return raw.map((e) => new GuildIntegration(this.client, e)) + } + + /** + * Chunk the Guild Members, i.e. cache them. + * @param options Options regarding the Members Request + * @param wait Whether to wait for all Members to come before resolving Promise or not. + * @param timeout Configurable timeout to cancel the wait to safely remove listener. + */ + async chunk( + options: RequestMembersOptions, + wait: boolean = false, + timeout: number = 60000 + ): Promise { + return await new Promise((resolve, reject) => { + this.client.gateway?.requestMembers(this.id, options) + if (!wait) return resolve(this) + else { + let chunked = false + const listener = (guild: Guild): void => { + if (guild.id === this.id) { + chunked = true + this.client.removeListener('guildMembersChunked', listener) + resolve(this) + } + } + this.client.on('guildMembersChunked', listener) + setTimeout(() => { + if (!chunked) { + this.client.removeListener('guildMembersChunked', listener) + } + }, timeout) + } + resolve(this) + }) } } @@ -257,7 +349,7 @@ export class GuildIntegration extends Base { revoked?: boolean application?: Application - constructor (client: Client, data: GuildIntegrationPayload) { + constructor(client: Client, data: GuildIntegrationPayload) { super(client, data) this.id = data.id diff --git a/src/structures/member.ts b/src/structures/member.ts index 3262643..f1a133f 100644 --- a/src/structures/member.ts +++ b/src/structures/member.ts @@ -1,44 +1,173 @@ -import { MemberRolesManager } from "../managers/memberRoles.ts" -import { Client } from '../models/client.ts' -import { MemberPayload } from '../types/guild.ts' -import { Permissions } from "../utils/permissions.ts" -import { Base } from './base.ts' -import { Guild } from "./guild.ts" -import { User } from './user.ts' - -export class Member extends Base { - id: string - user: User - nick?: string - roles: MemberRolesManager - joinedAt: string - premiumSince?: string - deaf: boolean - mute: boolean - guild: Guild - permissions: Permissions - - constructor (client: Client, data: MemberPayload, user: User, guild: Guild, perms?: Permissions) { - super(client) - this.id = data.user.id - this.user = user - this.nick = data.nick - this.guild = guild - this.roles = new MemberRolesManager(this.client, this.guild.roles, this) - this.joinedAt = data.joined_at - this.premiumSince = data.premium_since - this.deaf = data.deaf - this.mute = data.mute - if (perms !== undefined) this.permissions = perms - else this.permissions = new Permissions(Permissions.DEFAULT) - } - - protected readFromData (data: MemberPayload): void { - super.readFromData(data.user) - this.nick = data.nick ?? this.nick - this.joinedAt = data.joined_at ?? this.joinedAt - this.premiumSince = data.premium_since ?? this.premiumSince - this.deaf = data.deaf ?? this.deaf - this.mute = data.mute ?? this.mute - } -} +import { MemberRolesManager } from '../managers/memberRoles.ts' +import { Client } from '../models/client.ts' +import { GUILD_MEMBER } from '../types/endpoint.ts' +import { MemberPayload } from '../types/guild.ts' +import { Permissions } from '../utils/permissions.ts' +import { Base } from './base.ts' +import { Guild } from './guild.ts' +import { Role } from './role.ts' +import { User } from './user.ts' + +export interface MemberData { + nick?: string | null + roles?: Array + deaf?: boolean + mute?: boolean +} + +export class Member extends Base { + id: string + user: User + nick?: string + roles: MemberRolesManager + joinedAt: string + premiumSince?: string + deaf: boolean + mute: boolean + guild: Guild + permissions: Permissions + + constructor( + client: Client, + data: MemberPayload, + user: User, + guild: Guild, + perms?: Permissions + ) { + super(client) + this.id = data.user.id + this.user = user + this.nick = data.nick + this.guild = guild + this.roles = new MemberRolesManager(this.client, this.guild.roles, this) + this.joinedAt = data.joined_at + this.premiumSince = data.premium_since + this.deaf = data.deaf + this.mute = data.mute + if (perms !== undefined) this.permissions = perms + else this.permissions = new Permissions(Permissions.DEFAULT) + } + + get displayName(): string { + return this.nick !== undefined ? this.nick : this.user.username + } + + toString(): string { + return this.user.nickMention + } + + protected readFromData(data: MemberPayload): void { + super.readFromData(data.user) + this.nick = data.nick ?? this.nick + this.joinedAt = data.joined_at ?? this.joinedAt + this.premiumSince = data.premium_since ?? this.premiumSince + this.deaf = data.deaf ?? this.deaf + this.mute = data.mute ?? this.mute + } + + /** + * Update the Member data in cache (and this object). + */ + async fetch(): Promise { + const raw = await this.client.rest.get(this.id) + if (typeof raw !== 'object') throw new Error('Member not found') + await this.guild.members.set(this.id, raw) + this.readFromData(raw) + return this + } + + /** + * Edit the Member + * @param data Data to apply + */ + async edit(data: MemberData): Promise { + const payload = { + nick: data.nick, + roles: data.roles?.map((e) => (typeof e === 'string' ? e : e.id)), + deaf: data.deaf, + mute: data.mute, + } + const res = await this.client.rest.patch( + GUILD_MEMBER(this.guild.id, this.id), + payload, + undefined, + null, + true + ) + if (res.ok === true) { + if (data.nick !== undefined) + this.nick = data.nick === null ? undefined : data.nick + if (data.deaf !== undefined) this.deaf = data.deaf + if (data.mute !== undefined) this.mute = data.mute + } + return this + } + + /** + * New nickname to set. If empty, nick is reset + * @param nick New nickname + */ + async setNickname(nick?: string): Promise { + return await this.edit({ + nick: nick === undefined ? null : nick, + }) + } + + /** + * Reset nickname of the Member + */ + async resetNickname(): Promise { + return await this.setNickname() + } + + /** + * Set mute of a Member in VC + * @param mute Value to set + */ + async setMute(mute?: boolean): Promise { + return await this.edit({ + mute: mute === undefined ? false : mute, + }) + } + + /** + * Set deaf of a Member in VC + * @param deaf Value to set + */ + async setDeaf(deaf?: boolean): Promise { + return await this.edit({ + deaf: deaf === undefined ? false : deaf, + }) + } + + /** + * Unmute the Member from VC. + */ + async unmute(): Promise { + return await this.setMute(false) + } + + /** + * Kick the member. + */ + async kick(): Promise { + const resp = await this.client.rest.delete( + GUILD_MEMBER(this.guild.id, this.id), + undefined, + undefined, + null, + true + ) + if (resp.ok !== true) return false + else return true + } + + /** + * Ban the Member. + * @param reason Reason for the Ban. + * @param deleteMessagesDays Delete Old Messages? If yes, how much days. + */ + async ban(reason?: string, deleteOldMessages?: number): Promise { + return this.guild.bans.add(this.id, reason, deleteOldMessages) + } +} diff --git a/src/structures/voiceState.ts b/src/structures/voiceState.ts index b1672f6..026d5dd 100644 --- a/src/structures/voiceState.ts +++ b/src/structures/voiceState.ts @@ -1,49 +1,53 @@ -import { Client } from '../models/client.ts' -import { MemberPayload } from '../types/guild.ts' -import { VoiceStatePayload } from '../types/voice.ts' -import { Base } from './base.ts' - -export class VoiceState extends Base { - guildID?: string - channelID?: string - userID: string - member?: MemberPayload - sessionID: string - deaf: boolean - mute: boolean - selfDeaf: boolean - selfMute: boolean - selfStream?: boolean - selfVideo: boolean - suppress: boolean - - constructor (client: Client, data: VoiceStatePayload) { - super(client, data) - this.channelID = data.channel_id - this.sessionID = data.session_id - this.userID = data.user_id - this.deaf = data.deaf - this.mute = data.mute - this.selfDeaf = data.self_deaf - this.selfMute = data.self_mute - this.selfStream = data.self_stream - this.selfVideo = data.self_video - this.suppress = data.suppress - // TODO: Cache in Gateway Event Code - // cache.set('voiceState', `${this.guildID}:${this.userID}`, this) - } - - protected readFromData (data: VoiceStatePayload): void { - super.readFromData(data) - this.channelID = data.channel_id ?? this.channelID - this.sessionID = data.session_id ?? this.sessionID - this.userID = data.user_id ?? this.userID - this.deaf = data.deaf ?? this.deaf - this.mute = data.mute ?? this.mute - this.selfDeaf = data.self_deaf ?? this.selfDeaf - this.selfMute = data.self_mute ?? this.selfMute - this.selfStream = data.self_stream ?? this.selfStream - this.selfVideo = data.self_video ?? this.selfVideo - this.suppress = data.suppress ?? this.suppress - } -} +import { Client } from '../models/client.ts' +import { VoiceStatePayload } from '../types/voice.ts' +import { Base } from './base.ts' +import { Guild } from "./guild.ts" +import { VoiceChannel } from "./guildVoiceChannel.ts" +import { Member } from "./member.ts" +import { User } from "./user.ts" + +export class VoiceState extends Base { + guild?: Guild + channel: VoiceChannel | null + user: User + member?: Member + sessionID: string + deaf: boolean + mute: boolean + stream?: boolean + video: boolean + suppress: boolean + + constructor (client: Client, data: VoiceStatePayload, _data: { + user: User, + channel: VoiceChannel | null, + member?: Member, + guild?: Guild + }) { + super(client, data) + this.channel = _data.channel + this.sessionID = data.session_id + this.user = _data.user + this.member = _data.member + this.guild = _data.guild + this.deaf = data.deaf + this.mute = data.mute + this.deaf = data.self_deaf + this.mute = data.self_mute + this.stream = data.self_stream + this.video = data.self_video + this.suppress = data.suppress + } + + protected readFromData (data: VoiceStatePayload): void { + super.readFromData(data) + this.sessionID = data.session_id ?? this.sessionID + this.deaf = data.deaf ?? this.deaf + this.mute = data.mute ?? this.mute + this.deaf = data.self_deaf ?? this.deaf + this.mute = data.self_mute ?? this.mute + this.stream = data.self_stream ?? this.stream + this.video = data.self_video ?? this.video + this.suppress = data.suppress ?? this.suppress + } +} diff --git a/src/test/cmd.ts b/src/test/cmd.ts index 85efd56..68f0052 100644 --- a/src/test/cmd.ts +++ b/src/test/cmd.ts @@ -92,6 +92,14 @@ client.on('typingStart', (user, channel, at, guildData) => { console.log(`${user.tag} started typing in ${channel.id} at ${at}${guildData !== undefined ? `\nGuild: ${guildData.guild.name}` : ''}`) }) +client.on('voiceStateAdd', (state) => { + console.log('VC Join', state) +}) + +client.on('voiceStateRemove', (state) => { + console.log('VC Leave', state) +}) + // client.on('raw', (evt: string) => console.log(`EVENT: ${evt}`)) const files = Deno.readDirSync('./src/test/cmds') diff --git a/src/types/gateway.ts b/src/types/gateway.ts index aebf1e9..188103c 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -104,6 +104,7 @@ export enum GatewayEvents { Typing_Start = 'TYPING_START', User_Update = 'USER_UPDATE', Voice_Server_Update = 'VOICE_SERVER_UPDATE', + Voice_State_Update = 'VOICE_STATE_UPDATE', Webhooks_Update = 'WEBHOOKS_UPDATE' } diff --git a/src/types/guild.ts b/src/types/guild.ts index 1f92a00..3e7d7a1 100644 --- a/src/types/guild.ts +++ b/src/types/guild.ts @@ -1,4 +1,4 @@ -import { ApplicationPayload } from "./application.ts" +import { ApplicationPayload } from './application.ts' import { ChannelPayload } from './channel.ts' import { EmojiPayload } from './emoji.ts' import { PresenceUpdatePayload } from './gateway.ts' @@ -66,18 +66,18 @@ export interface MemberPayload { export enum MessageNotification { ALL_MESSAGES = 0, - ONLY_MENTIONS = 1 + ONLY_MENTIONS = 1, } export enum ContentFilter { DISABLED = 0, MEMBERS_WITHOUT_ROLES = 1, - ALL_MEMBERS = 3 + ALL_MEMBERS = 3, } export enum MFA { NONE = 0, - ELEVATED = 1 + ELEVATED = 1, } export enum Verification { @@ -85,19 +85,19 @@ export enum Verification { LOW = 1, MEDIUM = 2, HIGH = 3, - VERY_HIGH = 4 + VERY_HIGH = 4, } export enum PremiumTier { NONE = 0, TIER_1 = 1, TIER_2 = 2, - TIER_3 = 3 + TIER_3 = 3, } export enum SystemChannelFlags { SUPPRESS_JOIN_NOTIFICATIONS = 1 << 0, - SUPPRESS_PREMIUM_SUBSCRIPTIONS = 1 << 1 + SUPPRESS_PREMIUM_SUBSCRIPTIONS = 1 << 1, } export type GuildFeatures = @@ -116,7 +116,7 @@ export type GuildFeatures = export enum IntegrationExpireBehavior { REMOVE_ROLE = 0, - KICK = 1 + KICK = 1, } export interface IntegrationAccountPayload { @@ -140,4 +140,9 @@ export interface GuildIntegrationPayload { subscriber_count?: number revoked?: boolean application?: ApplicationPayload -} \ No newline at end of file +} + +export interface GuildBanPayload { + reason: string | null + user: UserPayload +} diff --git a/src/types/voice.ts b/src/types/voice.ts index c6b3143..c796d1c 100644 --- a/src/types/voice.ts +++ b/src/types/voice.ts @@ -1,45 +1,45 @@ -// https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice -import { MemberPayload } from './guild.ts' - -export enum VoiceOpcodes { // VoiceOpcodes 추가 - UnderC - - IDENTIFY = 0, - SELECT_PROTOCOL = 1, - READY = 2, - HEARTBEAT = 3, - SESSION_DESCRIPTION = 4, - SPEAKING = 6, - HEARTBEAT_ACK = 6, - RESUME = 7, - HELLO = 8, - RESUMED = 9, - CLIENT_DISCONNECT = 13 -} - -export enum VoiceCloseCodes { - UNKNOWN_OPCODE = 4001, - NOT_AUTHENTICATED = 4003, - AUTHENTICATION_FAILED = 4004, - ALREADY_AUTHENTICATED = 4005, - SESSION_NO_LONGER_VALID = 4006, - SESSION_TIMEOUT = 4009, - SERVER_NOT_FOUNT = 4011, - UNKNOWN_PROTOCOL = 4012, - DISCONNECTED = 4014, - VOICE_SERVER_CRASHED = 4015, - UNKNOWN_ENCRYPTION_MODE = 4016 -} - -export interface VoiceStatePayload { - guild_id?: string - channel_id?: string - user_id: string - member?: MemberPayload - session_id: string - deaf: boolean - mute: boolean - self_deaf: boolean - self_mute: boolean - self_stream?: boolean - self_video: boolean - suppress: boolean -} +// https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice +import { MemberPayload } from './guild.ts' + +export enum VoiceOpcodes { // VoiceOpcodes 추가 - UnderC - + IDENTIFY = 0, + SELECT_PROTOCOL = 1, + READY = 2, + HEARTBEAT = 3, + SESSION_DESCRIPTION = 4, + SPEAKING = 6, + HEARTBEAT_ACK = 6, + RESUME = 7, + HELLO = 8, + RESUMED = 9, + CLIENT_DISCONNECT = 13 +} + +export enum VoiceCloseCodes { + UNKNOWN_OPCODE = 4001, + NOT_AUTHENTICATED = 4003, + AUTHENTICATION_FAILED = 4004, + ALREADY_AUTHENTICATED = 4005, + SESSION_NO_LONGER_VALID = 4006, + SESSION_TIMEOUT = 4009, + SERVER_NOT_FOUNT = 4011, + UNKNOWN_PROTOCOL = 4012, + DISCONNECTED = 4014, + VOICE_SERVER_CRASHED = 4015, + UNKNOWN_ENCRYPTION_MODE = 4016 +} + +export interface VoiceStatePayload { + guild_id?: string + channel_id: string | null + user_id: string + member?: MemberPayload + session_id: string + deaf: boolean + mute: boolean + self_deaf: boolean + self_mute: boolean + self_stream?: boolean + self_video: boolean + suppress: boolean +} diff --git a/tsconfig.json b/tsconfig.json index 5f09fcc..a4477bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,70 +1,70 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - /* Basic Options */ - // "incremental": true, /* Enable incremental compilation */ - "target": "ESNext", - /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ - "module": "ESNext", - /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ - "lib": [ - "esnext" - ] /* Specify library files to be included in the compilation. */, - // "allowJs": true, /* Allow javascript files to be compiled. */ - // "checkJs": true, /* Report errors in .js files. */ - // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - // "declaration": true, /* Generates corresponding '.d.ts' file. */ - // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - // "sourceMap": true, /* Generates corresponding '.map' file. */ - // "outFile": "./", /* Concatenate and emit output to single file. */ - // "outDir": "./", /* Redirect output structure to the directory. */ - // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - // "composite": true, /* Enable project compilation */ - // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ - // "removeComments": true, /* Do not emit comments to output. */ - // "noEmit": true, /* Do not emit outputs. */ - // "importHelpers": true, /* Import emit helpers from 'tslib'. */ - // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ - "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, - /* Strict Type-Checking Options */ - "strict": true, - /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* Enable strict null checks. */ - // "strictFunctionTypes": true, /* Enable strict checking of function types. */ - // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ - // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ - // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ - // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ - /* Additional Checks */ - // "noUnusedLocals": true, /* Report errors on unused locals. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - /* Module Resolution Options */ - "moduleResolution": "node", - /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ - // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ - // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true, - /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - /* Source Map Options */ - // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ - // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ - /* Experimental Options */ - // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ - "emitDecoratorMetadata": false /* Enables experimental support for emitting type metadata for decorators. */, - /* Advanced Options */ - "skipLibCheck": true, - /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ - } -} +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "ESNext", + /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "ESNext", + /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "lib": [ + "esnext" + ] /* Specify library files to be included in the compilation. */, + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, + /* Strict Type-Checking Options */ + "strict": true, + /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Module Resolution Options */ + "moduleResolution": "node", + /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, + /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + "emitDecoratorMetadata": false /* Enables experimental support for emitting type metadata for decorators. */, + /* Advanced Options */ + "skipLibCheck": true, + /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +}