diff --git a/examples/js/components/interactioncommentclient.js b/examples/js/components/interactioncommentclient.js new file mode 100644 index 00000000..b7624e09 --- /dev/null +++ b/examples/js/components/interactioncommentclient.js @@ -0,0 +1,116 @@ +const { Constants, InteractionCommandClient, Utils } = require('../../../lib'); +const { InteractionCallbackTypes, MessageFlags, Permissions } = Constants; +const { Components, ComponentActionRow } = Utils; + + +const guildId = ''; +const token = ''; +const interactionClient = new InteractionCommandClient(token); + +interactionClient.add({ + description: 'ping!', + name: 'ping', + guildIds: [guildId], + run: (context) => { + const actionRow = new ComponentActionRow(); + actionRow.createButton({ + label: 'ping, but clear buttons', + run: (componentContext) => componentContext.editOrRespond({content: 'pong from the button!', components: []}), + }); + actionRow.createButton({ + label: 'ping, but respond', + run: (componentContext) => { + return componentContext.respond(InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, { + content: 'pong!', + flags: MessageFlags.EPHEMERAL, + }); + }, + }); + return context.editOrRespond({content: 'pong!', components: [actionRow]}); + }, +}); + +interactionClient.add({ + description: 'Click Test', + name: 'click-test', + guildIds: [guildId], + run: (context) => { + const components = new Components({ + timeout: 5 * (60 * 1000), + onTimeout: () => context.editOrRespond({content: 'didnt click for 5 minutes', components: []}), + }); + { + const actionRow = components.createActionRow(); + actionRow.createButton({ + label: 'click me', + run: (componentContext) => componentContext.editOrRespond({content: `clicked by ${componentContext.user}`, components: []}), + }); + } + return context.editOrRespond({content: 'click the button', components}); + }, +}); + +interactionClient.add({ + description: 'Give yourself a role', + name: 'give-role', + guildIds: [guildId], + disableDm: true, + permissions: [Permissions.MANAGE_ROLES], + permissionsClient: [Permissions.MANAGE_ROLES], + onPermissionsFail: (context, permissions) => context.editOrRespond('You need manage roles'), + onPermissionsFailClient: (context, permissions) => context.editOrRespond('The bot needs manage roles'), + onBefore: (context) => context.me.canEdit(context.member), + onCancel: (context) => context.editOrRespond('The bot cannot edit you, change some role hierachy or something'), + run: (context) => { + const components = new Components({ + timeout: 5 * (60 * 1000), + onTimeout: () => context.editOrRespond({content: 'Choosing Expired', components: []}), + run: async (componentContext) => { + if (componentContext.userId !== context.userId || !context.values) { + // ignore the press because it wasnt from the initiator + return componentContext.respond(InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE); + } + + const roleIds = context.values; + if (roleIds.length) { + if (context.me.canEdit(context.member)) { + for (let roleId of context.values) { + if (!context.member.roles.has(roleId) && context.me.canEditRole(roleId)) { + await context.member.addRole(roleId); + } + } + context.editOrRespond(`Ok, gave ${roleIds.length} roles to you`); + } else { + context.editOrRespond('Can\'t edit you anymore'); + } + } else { + context.editOrRespond('choose some roles!'); + } + }, + }); + + if (context.guild) { + const rolesWeCanAdd = context.guild.roles.filter((role) => { + return !context.member.roles.has(roleId) && context.me.canEditRole(role); + }); + if (rolesWeCanAdd.length) { + components.createSelectMenu({ + placeholder: 'Choose', + options: rolesWeCanAdd.slice(0, 25).map((role) => { + return { + label: role.name, + value: role.id, + }; + }), + }); + } + } + return context.editOrRespond({content: 'choose a role', components, flags: MessageFlags.EPHEMERAL}); + }, +}); + + +(async () => { + const cluster = await interactionClient.run(); + console.log('running'); +})(); diff --git a/package.json b/package.json index ce89fbf7..7d523b0f 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "dependencies": { "@types/node": "^14.17.2", "@types/node-fetch": "^2.5.10", - "detritus-client-rest": "^0.10.3", - "detritus-client-socket": "^0.8.2-beta.0", + "detritus-client-rest": "^0.10.4", + "detritus-client-socket": "^0.8.2", "detritus-utils": "^0.4.0" }, "description": "A Typescript NodeJS library to interact with Discord's API, both Rest and Gateway.", @@ -56,5 +56,5 @@ "typedoc": "typedoc" }, "types": "lib/index.d.ts", - "version": "0.16.1" + "version": "0.16.2" } diff --git a/src/client.ts b/src/client.ts index 3d9afe93..78221998 100644 --- a/src/client.ts +++ b/src/client.ts @@ -77,6 +77,8 @@ import { UserMe, } from './structures'; +import { Components, ComponentActionRow, ComponentActionRowData } from './utils'; + interface GatewayOptions extends Gateway.SocketOptions, GatewayHandlerOptions { @@ -379,6 +381,20 @@ export class ShardClient extends EventSpewer { return oauth2Application; } + hookComponents( + listenerId: string, + components: Components | Array, + timeout?: number, + ): Components { + if (components instanceof Components) { + components.id = listenerId; + } else { + components = new Components({components, id: listenerId, timeout: timeout || 0}); + } + this.gatewayHandler._componentHandler.insert(components); + return components; + } + isOwner(userId: string): boolean { return this.owners.has(userId); } diff --git a/src/constants.ts b/src/constants.ts index 85cc60d8..ce35c92e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,7 +35,7 @@ export { export const Package = Object.freeze({ URL: 'https://github.com/detritusjs/client', - VERSION: '0.16.1', + VERSION: '0.16.2', }); export type Snowflake = number | string; @@ -43,13 +43,14 @@ export type Snowflake = number | string; export const IS_TS_NODE = Symbol.for('ts-node.register.instance') in process; - export const DEFAULT_MAX_MEMBERS = 250000; export const DEFAULT_MAX_PRESENCES = 5000; export const DEFAULT_MAX_VIDEO_CHANNEL_USERS = 25; export const LOCAL_GUILD_ID = '@me'; +export const MAX_ACTION_ROW_BUTTONS = 5; +export const MAX_ACTION_ROW_SELECT_MENUS = 1; export const MAX_ATTACHMENT_SIZE = 8 * 1024 * 1024; export const MAX_ATTACHMENT_SIZE_PREMIUM = 50 * 1024 * 1024; export const MAX_BITRATE = 96000; @@ -950,6 +951,27 @@ export const PERMISSIONS_LURKER = [ Permissions.NONE, ); +export const PERMISSIONS_FOR_GUILD = [ + Permissions.ADMINISTRATOR, +].reduce( + (permissions: bigint, permission: bigint) => permissions | permission, + Permissions.NONE, +); + +export const PERMISSIONS_FOR_CHANNEL_TEXT = [ + Permissions.ADMINISTRATOR, +].reduce( + (permissions: bigint, permission: bigint) => permissions | permission, + Permissions.NONE, +); + +export const PERMISSIONS_FOR_CHANNEL_VOICE = [ + Permissions.ADMINISTRATOR, +].reduce( + (permissions: bigint, permission: bigint) => permissions | permission, + Permissions.NONE, +); + export enum PlatformTypes { BATTLENET = 'battlenet', @@ -1541,6 +1563,7 @@ export const DiscordKeys = Object.freeze({ THREAD_METADATA: 'thread_metadata', THREADS: 'threads', THUMBNAIL: 'thumbnail', + TIMEOUT: 'timeout', TIMESTAMP: 'timestamp', TIMESTAMPS: 'timestamps', TITLE: 'title', @@ -1907,6 +1930,7 @@ export const DetritusKeys = Object.freeze({ [DiscordKeys.THREAD_METADATA]: 'threadMetadata', [DiscordKeys.THREADS]: 'threads', [DiscordKeys.THUMBNAIL]: 'thumbnail', + [DiscordKeys.TIMEOUT]: 'timeout', [DiscordKeys.TIMESTAMP]: 'timestamp', [DiscordKeys.TIMESTAMPS]: 'timestamps', [DiscordKeys.TITLE]: 'title', diff --git a/src/gateway/componenthandler.ts b/src/gateway/componenthandler.ts new file mode 100644 index 00000000..2a7dd2c4 --- /dev/null +++ b/src/gateway/componenthandler.ts @@ -0,0 +1,88 @@ +import { Timers } from 'detritus-utils'; + +import { BaseCollection } from '../collections'; +import { Interaction, InteractionDataComponent } from '../structures'; +import { Components, ComponentContext } from '../utils'; + + +export class ComponentHandler { + listeners = new BaseCollection(); + + delete(listenerId: string): boolean { + if (this.listeners.has(listenerId)) { + const listener = this.listeners.get(listenerId)!; + if (listener._timeout) { + listener._timeout.stop(); + } + return this.listeners.delete(listenerId); + } + return false; + } + + async execute(interaction: Interaction): Promise { + if (!this.listeners.length || !interaction.isFromMessageComponent || !interaction.message || !interaction.data) { + return; + } + const message = interaction.message; + const data = interaction.data as InteractionDataComponent; + + const listener = this.listeners.get(message.interaction?.id || message.id) || this.listeners.get(message.id); + if (listener) { + try { + if (typeof(listener.run) === 'function') { + const context = new ComponentContext(interaction); + await Promise.resolve(listener.run(context)); + } + } catch(error) { + + } + + for (let actionRow of listener.components) { + const component = actionRow.components.find((c) => c.customId === data.customId); + if (component) { + try { + if (typeof(component.run) === 'function') { + const context = new ComponentContext(interaction); + await Promise.resolve(component.run(context)); + } + } catch(error) { + + } + break; + } + } + } + } + + insert(listener: Components) { + const listenerId = listener.id; + if (listenerId) { + if (this.listeners.has(listenerId)) { + const oldListener = this.listeners.get(listenerId)!; + if (oldListener._timeout) { + oldListener._timeout.stop(); + } + this.delete(listenerId); + } + + if (listener.timeout) { + const timeout = listener._timeout = new Timers.Timeout(); + timeout.start(listener.timeout, async () => { + if (this.listeners.get(listenerId) === listener) { + this.delete(listenerId); + + try { + if (typeof(listener.onTimeout) === 'function') { + await Promise.resolve(listener.onTimeout()); + } + } catch(error) { + + } + } + }); + } + + this.listeners.set(listenerId, listener); + } + } +} diff --git a/src/gateway/handler.ts b/src/gateway/handler.ts index 08fb8e7c..57ccea28 100644 --- a/src/gateway/handler.ts +++ b/src/gateway/handler.ts @@ -38,6 +38,7 @@ import { } from '../structures'; import { GatewayClientEvents } from './clientevents'; +import { ComponentHandler } from './componenthandler'; import { GatewayRawEvents } from './rawevents'; @@ -62,6 +63,7 @@ export interface ChunkWaiting { export class GatewayHandler { readonly client: ShardClient; readonly _chunksWaiting = new BaseCollection(); + readonly _componentHandler = new ComponentHandler(); disabledEvents: BaseSet; dispatchHandler: GatewayDispatchHandler; @@ -156,6 +158,12 @@ export class GatewayDispatchHandler { async [GatewayDispatchEvents.READY](data: GatewayRawEvents.Ready) { this.client.reset(false); + for (let [listenerId, listener] of this.handler._componentHandler.listeners) { + if (!listener.timeout) { + this.handler._componentHandler.listeners.delete(listenerId); + } + } + for (let [nonce, cache] of this.handler._chunksWaiting) { cache.promise.reject(new Error('Gateway re-identified before a result came.')); } @@ -398,8 +406,10 @@ export class GatewayDispatchHandler { if (channel.isText) { for (let [messageId, message] of this.client.messages) { if (message.channelId === channel.id) { + message.deleted = true; this.client.messages.delete(messageId); } + this.handler._componentHandler.delete(messageId); } } @@ -678,7 +688,9 @@ export class GatewayDispatchHandler { for (let [messageId, message] of this.client.messages) { if (message.guildId === guildId) { + message.deleted = true; this.client.messages.delete(messageId); + this.handler._componentHandler.delete(messageId); } } @@ -1219,6 +1231,10 @@ export class GatewayDispatchHandler { const payload: GatewayClientEvents.InteractionCreate = {_raw: data, interaction}; this.client.emit(ClientEvents.INTERACTION_CREATE, payload); + + if (interaction.isFromMessageComponent) { + this.handler._componentHandler.execute(interaction); + } } [GatewayDispatchEvents.INVITE_CREATE](data: GatewayRawEvents.InviteCreate) { @@ -1342,6 +1358,8 @@ export class GatewayDispatchHandler { } } + this.handler._componentHandler.delete(messageId); + const payload: GatewayClientEvents.MessageDelete = {channelId, guildId, message, messageId, raw: data}; this.client.emit(ClientEvents.MESSAGE_DELETE, payload); } @@ -1361,6 +1379,7 @@ export class GatewayDispatchHandler { } else { messages.set(messageId, null); } + this.handler._componentHandler.delete(messageId); } const payload: GatewayClientEvents.MessageDeleteBulk = {amount, channelId, guildId, messages, raw: data}; diff --git a/src/interaction/command.ts b/src/interaction/command.ts index 3f6e565d..54630102 100644 --- a/src/interaction/command.ts +++ b/src/interaction/command.ts @@ -273,6 +273,9 @@ export class InteractionCommand extends Structu } if (data.guildIds) { + if (data.global === undefined) { + this.global = false; + } this.guildIds = new BaseSet(data.guildIds); } @@ -587,7 +590,7 @@ export class InteractionCommandOption extends S } get key(): string { - return `${this.name}-${this.description}-${this.type}-${this._optionsKey}-${this._choicesKey}`; + return `${this.name}-${this.description}-${this.type}-${!!this.required}-${this._optionsKey}-${this._choicesKey}`; } get length(): number { diff --git a/src/interaction/context.ts b/src/interaction/context.ts index 26289c4a..6b1299f5 100644 --- a/src/interaction/context.ts +++ b/src/interaction/context.ts @@ -52,7 +52,6 @@ export class InteractionContext { } /* Generic Client Properties */ - get application() { return this.client.application; } @@ -171,7 +170,6 @@ export class InteractionContext { } /* Interaction Properties */ - get data(): InteractionDataApplicationCommand { return this.interaction.data as InteractionDataApplicationCommand; } @@ -268,6 +266,7 @@ export class InteractionContext { return null; } + /* Functions */ createMessage(options: RequestTypes.ExecuteWebhook | string = {}) { return this.interaction.createMessage(options); } diff --git a/src/rest/client.ts b/src/rest/client.ts index 0c201fd5..fee2ccc7 100644 --- a/src/rest/client.ts +++ b/src/rest/client.ts @@ -5,10 +5,12 @@ import { RestClientEvents, } from 'detritus-client-rest'; import { AuthTypes, RestEvents } from 'detritus-client-rest/lib/constants'; +import { Snowflake } from 'detritus-utils'; import { ShardClient } from '../client'; import { BaseCollection } from '../collections/basecollection'; import { ClientEvents } from '../constants'; +import { createComponentListenerOrNone } from '../utils'; import { Application, @@ -19,6 +21,7 @@ import { Channel, ChannelDM, ChannelDMGroup, + ChannelGuildThread, ConnectedAccount, Emoji, Gift, @@ -354,12 +357,22 @@ export class RestClient { return new Invite(this.client, data); } - createChannelMessageThread( + async createChannelMessageThread( channelId: string, messageId: string, options: RequestTypes.CreateChannelMessageThread, - ) { - return this.raw.createChannelMessageThread(channelId, messageId, options); + ): Promise { + const data = await this.raw.createChannelMessageThread(channelId, messageId, options); + let channel: ChannelGuildThread; + if (this.client.channels.has(data.id)) { + channel = this.client.channels.get(data.id) as ChannelGuildThread; + channel.merge(data); + // this should never happen lmao + } else { + channel = createChannelFromData(this.client, data) as ChannelGuildThread; + this.client.channels.insert(channel); + } + return channel; } createChannelStoreListingGrantEntitlement( @@ -368,11 +381,21 @@ export class RestClient { return this.raw.createChannelStoreListingGrantEntitlement(channelId); } - createChannelThread( + async createChannelThread( channelId: string, options: RequestTypes.CreateChannelThread, - ) { - return this.raw.createChannelThread(channelId, options); + ): Promise { + const data = await this.raw.createChannelThread(channelId, options); + let channel: ChannelGuildThread; + if (this.client.channels.has(data.id)) { + channel = this.client.channels.get(data.id) as ChannelGuildThread; + channel.merge(data); + // this should never happen lmao + } else { + channel = createChannelFromData(this.client, data) as ChannelGuildThread; + this.client.channels.insert(channel); + } + return channel; } async createDm( @@ -490,13 +513,25 @@ export class RestClient { return new Template(this.client, data); } - createInteractionResponse( + async createInteractionResponse( interactionId: string, token: string, options: RequestTypes.CreateInteractionResponse | number, data?: RequestTypes.CreateInteractionResponseInnerPayload | string, ) { - return this.raw.createInteractionResponse(interactionId, token, options, data); + const listener = createComponentListenerOrNone((typeof(options) === 'object') ? options.data || data : data, interactionId); + const rawData = await this.raw.createInteractionResponse(interactionId, token, options, data); + + if (listener || listener === false) { + const listenerId = interactionId; + if (listener) { + this.client.gatewayHandler._componentHandler.insert(listener); + } else { + this.client.gatewayHandler._componentHandler.delete(listenerId); + } + } + + return rawData; } createLobby( @@ -522,6 +557,8 @@ export class RestClient { channelId: string, options: RequestTypes.CreateMessage | string = {}, ): Promise { + const listener = createComponentListenerOrNone(options); + const data = await this.raw.createMessage(channelId, options); if (this.client.channels.has(data.channel_id)) { const channel = this.client.channels.get(data.channel_id)!; @@ -529,8 +566,20 @@ export class RestClient { data.guild_id = channel.guildId; } } + const message = new Message(this.client, data); this.client.messages.insert(message); + + if (listener || listener === false) { + const listenerId = message.id; + if (listener) { + listener.id = listenerId; + this.client.gatewayHandler._componentHandler.insert(listener); + } else { + this.client.gatewayHandler._componentHandler.delete(listenerId); + } + } + return message; } @@ -644,6 +693,7 @@ export class RestClient { } else { channel = createChannelFromData(this.client, data); } + // go through each message and mark them as deleted or wait for the event? return channel; } @@ -666,6 +716,7 @@ export class RestClient { guildId: string, options: RequestTypes.DeleteGuild = {}, ) { + // go through each message and mark them as deleted or wait for the event? return this.raw.deleteGuild(guildId, options); } @@ -751,6 +802,7 @@ export class RestClient { const message = this.client.messages.get(messageId)!; message.deleted = true; } + this.client.gatewayHandler._componentHandler.delete(messageId); return data; } @@ -850,6 +902,7 @@ export class RestClient { const message = this.client.messages.get(messageId)!; message.deleted = true; } + this.client.gatewayHandler._componentHandler.delete(messageId); return data; } @@ -1155,6 +1208,8 @@ export class RestClient { options: RequestTypes.EditMessage | string = {}, updateCache: boolean = true, ): Promise { + const listener = createComponentListenerOrNone(options); + const data = await this.raw.editMessage(channelId, messageId, options); let message: Message; if (updateCache && this.client.messages.has(data.id)) { @@ -1165,6 +1220,17 @@ export class RestClient { message = new Message(this.client, data); this.client.messages.insert(message); } + + if (listener || listener === false) { + const listenerId = message.id; + if (listener) { + listener.id = listenerId; + this.client.gatewayHandler._componentHandler.insert(listener); + } else { + this.client.gatewayHandler._componentHandler.delete(listenerId); + } + } + return message; } @@ -1237,6 +1303,12 @@ export class RestClient { options: RequestTypes.EditWebhookTokenMessage = {}, updateCache: boolean = true, ): Promise { + const listener = createComponentListenerOrNone(options); + if (listener || listener === false) { + // it'll be from `interaction.respond()` then `interaction.editResponse()` + this.client.gatewayHandler._componentHandler.delete(webhookId); + } + const data = await this.raw.editWebhookTokenMessage(webhookId, webhookToken, messageId, options); let message: Message; if (updateCache && this.client.messages.has(data.id)) { @@ -1246,6 +1318,17 @@ export class RestClient { message = new Message(this.client, data); this.client.messages.insert(message); } + + if (listener || listener === false) { + const listenerId = message.id; + if (listener) { + listener.id = listenerId; + this.client.gatewayHandler._componentHandler.insert(listener); + } else { + this.client.gatewayHandler._componentHandler.delete(listenerId); + } + } + return message; } @@ -1267,12 +1350,30 @@ export class RestClient { options: RequestTypes.ExecuteWebhook | string = {}, compatibleType?: string, ): Promise { + const listener = createComponentListenerOrNone(options); const data = await this.raw.executeWebhook(webhookId, webhookToken, options, compatibleType); if (data) { const message = new Message(this.client, data); this.client.messages.insert(message); + + if (listener || listener === false) { + const listenerId = message.id; + if (listener) { + listener.id = listenerId; + this.client.gatewayHandler._componentHandler.insert(listener); + } else { + this.client.gatewayHandler._componentHandler.delete(listenerId); + } + } + return message; } + + if (listener) { + listener.id = Snowflake.generate().id; + this.client.gatewayHandler._componentHandler.insert(listener); + } + return data; } @@ -1440,7 +1541,7 @@ export class RestClient { const data = await this.raw.fetchChannelThreadsActive(channelId); const hasMore = data['has_more']; const members = new BaseCollection>(); - const threads = new BaseCollection(); + const threads = new BaseCollection(); for (let raw of data.members) { const threadMember = new ThreadMember(this.client, raw); @@ -1455,7 +1556,7 @@ export class RestClient { } for (let raw of data.threads) { - const thread = createChannelFromData(this.client, raw); + const thread = createChannelFromData(this.client, raw) as ChannelGuildThread; threads.set(thread.id, thread); } @@ -1469,7 +1570,7 @@ export class RestClient { const data = await this.raw.fetchChannelThreadsArchivedPrivate(channelId, options); const hasMore = data['has_more']; const members = new BaseCollection>(); - const threads = new BaseCollection(); + const threads = new BaseCollection(); for (let raw of data.members) { const threadMember = new ThreadMember(this.client, raw); @@ -1484,7 +1585,7 @@ export class RestClient { } for (let raw of data.threads) { - const thread = createChannelFromData(this.client, raw); + const thread = createChannelFromData(this.client, raw) as ChannelGuildThread; threads.set(thread.id, thread); } @@ -1498,7 +1599,7 @@ export class RestClient { const data = await this.raw.fetchChannelThreadsArchivedPrivateJoined(channelId, options); const hasMore = data['has_more']; const members = new BaseCollection>(); - const threads = new BaseCollection(); + const threads = new BaseCollection(); for (let raw of data.members) { const threadMember = new ThreadMember(this.client, raw); @@ -1513,7 +1614,7 @@ export class RestClient { } for (let raw of data.threads) { - const thread = createChannelFromData(this.client, raw); + const thread = createChannelFromData(this.client, raw) as ChannelGuildThread; threads.set(thread.id, thread); } @@ -1527,7 +1628,7 @@ export class RestClient { const data = await this.raw.fetchChannelThreadsArchivedPublic(channelId, options); const hasMore = data['has_more']; const members = new BaseCollection>(); - const threads = new BaseCollection(); + const threads = new BaseCollection(); for (let raw of data.members) { const threadMember = new ThreadMember(this.client, raw); @@ -1542,7 +1643,7 @@ export class RestClient { } for (let raw of data.threads) { - const thread = createChannelFromData(this.client, raw); + const thread = createChannelFromData(this.client, raw) as ChannelGuildThread; threads.set(thread.id, thread); } @@ -1734,7 +1835,7 @@ export class RestClient { ): Promise> { const data = await this.raw.fetchGuildEmojis(guildId); - if (this.client.guilds.has(guildId)) { + if (this.client.emojis.enabled && this.client.guilds.has(guildId)) { const guild = this.client.guilds.get(guildId)!; guild.merge({emojis: data}); return guild.emojis; diff --git a/src/rest/types.ts b/src/rest/types.ts index 5426ebfb..811fb8f0 100644 --- a/src/rest/types.ts +++ b/src/rest/types.ts @@ -1,7 +1,7 @@ import { BaseCollection } from '../collections/basecollection'; import { - Channel, + ChannelGuildThread, ThreadMember, User, } from '../structures'; @@ -11,25 +11,25 @@ export namespace RestResponses { export interface FetchChannelThreadsActive { hasMore: boolean, members: BaseCollection>, - threads: BaseCollection, + threads: BaseCollection, } export interface FetchChannelThreadsArchivedPrivate { hasMore: boolean, members: BaseCollection>, - threads: BaseCollection, + threads: BaseCollection, } export interface FetchChannelThreadsArchivedPrivateJoined { hasMore: boolean, members: BaseCollection>, - threads: BaseCollection, + threads: BaseCollection, } export interface FetchChannelThreadsArchivedPublic { hasMore: boolean, members: BaseCollection>, - threads: BaseCollection, + threads: BaseCollection, } export interface FetchGuildBans extends BaseCollection { diff --git a/src/structures/applicationcommand.ts b/src/structures/applicationcommand.ts index 5cce1b54..015a151f 100644 --- a/src/structures/applicationcommand.ts +++ b/src/structures/applicationcommand.ts @@ -141,7 +141,7 @@ export class ApplicationCommandOption extends BaseStructure { } get key(): string { - return `${this.name}-${this.description}-${this.type}-${this._optionsKey}-${this._choicesKey}`; + return `${this.name}-${this.description}-${this.type}-${!!this.required}-${this._optionsKey}-${this._choicesKey}`; } mergeValue(key: string, value: any): void { diff --git a/src/structures/member.ts b/src/structures/member.ts index f47aa20f..d3f352de 100644 --- a/src/structures/member.ts +++ b/src/structures/member.ts @@ -354,13 +354,32 @@ export class Member extends UserMixin { return false; } + /* doesnt do any permission checks */ + canEditRole(roleId: Role | string): boolean { + let role: Role; + if (roleId instanceof Role) { + role = roleId; + } else { + if (this.client.roles.has(this.guildId, roleId)) { + role = this.client.roles.get(this.guildId, roleId)!; + } else { + return false; + } + } + const us = this.highestRole; + if (us) { + return role.position < us.position; + } + return false; + } + permissionsIn(channelId: Channel | string): bigint { let channel: Channel; if (channelId instanceof ChannelBase) { channel = channelId; } else { if (this.client.channels.has(channelId)) { - channel = this.client.channels.get(channelId) as Channel; + channel = this.client.channels.get(channelId)!; } else { return Permissions.NONE; } diff --git a/src/structures/message.ts b/src/structures/message.ts index 3958f0f7..395f5c8f 100644 --- a/src/structures/message.ts +++ b/src/structures/message.ts @@ -176,6 +176,9 @@ export class Message extends BaseStructure { } get canDelete(): boolean { + if (this.hasFlagEphemeral) { + return false; + } if (this.fromMe || this.canManage) { if (this.type in MessageTypesDeletable && MessageTypesDeletable[this.type]) { return true; @@ -184,6 +187,16 @@ export class Message extends BaseStructure { return false; } + get canEdit(): boolean { + if (this.hasFlagEphemeral) { + return false; + } + if (this.fromMe || this.canManage) { + return !this.deleted; + } + return false; + } + get canManage(): boolean { const channel = this.channel; return !!(channel && channel.canManageMessages); @@ -286,6 +299,10 @@ export class Message extends BaseStructure { return this.hasFlag(MessageFlags.CROSSPOSTED); } + get hasFlagEphemeral(): boolean { + return this.hasFlag(MessageFlags.EPHEMERAL); + } + get hasFlagIsCrossposted(): boolean { return this.hasFlag(MessageFlags.IS_CROSSPOST); } @@ -668,6 +685,12 @@ export class Message extends BaseStructure { } } }; return; + case DiscordKeys.FLAGS: { + this.flags = value; + if (this.hasFlagEphemeral) { + this.deleted = true; + } + }; return; case DiscordKeys.INTERACTION: { if (this.interaction) { this.interaction.merge(value); diff --git a/src/utils/components.ts b/src/utils/components.ts deleted file mode 100644 index 86177380..00000000 --- a/src/utils/components.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { RequestTypes } from 'detritus-client-rest'; - -import { BaseSet } from '../collections/baseset'; -import { - DetritusKeys, - DiscordKeys, - DiscordRegexNames, - MessageComponentButtonStyles, - MessageComponentTypes, -} from '../constants'; -import { GatewayRawEvents } from '../gateway/rawevents'; -import { Structure } from '../structures/basestructure'; -import { Emoji } from '../structures/emoji'; -import { regex as discordRegex } from '../utils'; - - - -export type ComponentEmojiData = {animated?: boolean, id?: null | string, name: string} | string | Emoji; - -export interface ComponentData extends Omit, 'emoji'> { - customId?: string, - emoji?: ComponentEmojiData, - maxValues?: number, - minValues?: number, -} - -export interface ComponentSelectMenuOptionData extends Omit, 'emoji'> { - emoji?: ComponentEmojiData, -} - - -const keysComponentActionRow = new BaseSet([ - DiscordKeys.COMPONENTS, - DiscordKeys.TYPE, -]); - -/** - * Utils Component Action Row Structure - * @category Utils - */ - export class ComponentActionRow extends Structure { - readonly _keys = keysComponentActionRow; - - components: Array = []; - type = MessageComponentTypes.ACTION_ROW; - - constructor(data: ComponentData = {}) { - super(); - this.merge(data); - this.type = MessageComponentTypes.ACTION_ROW; - } - - addButton(data: ComponentButton | ComponentData = {}): this { - if (data instanceof ComponentButton) { - return this.addComponent(data); - } - return this.addComponent(new ComponentButton(data)); - } - - addComponent(component: ComponentButton | ComponentSelectMenu): this { - this.components.push(component); - return this; - } - - addSelectMenu(data: ComponentSelectMenu | ComponentData = {}): this { - if (data instanceof ComponentSelectMenu) { - return this.addComponent(data); - } - return this.addComponent(new ComponentSelectMenu(data)); - } - - createButton(data: ComponentData = {}): ComponentButton { - const component = new ComponentButton(data); - this.addComponent(component); - return component; - } - - createSelectMenu(data: ComponentData = {}): ComponentSelectMenu { - const component = new ComponentSelectMenu(data); - this.addComponent(component); - return component; - } - - mergeValue(key: string, value: any): void { - switch (key) { - case DiscordKeys.COMPONENTS: { - this.components.length = 0; - for (let raw of value) { - switch (raw.type) { - case MessageComponentTypes.BUTTON: { - const component = new ComponentButton(raw); - this.components.push(component); - }; break; - case MessageComponentTypes.SELECT_MENU: { - const component = new ComponentSelectMenu(raw); - this.components.push(component); - }; break; - default: { - throw new Error(`Unknown component type ${raw.type}`); - }; - } - } - }; return; - } - return super.mergeValue(key, value); - } - - toJSON(): RequestTypes.RawChannelMessageComponent { - return super.toJSON() as RequestTypes.RawChannelMessageComponent; - } -} - - -const keysComponentButton = new BaseSet([ - DiscordKeys.CUSTOM_ID, - DiscordKeys.DISABLED, - DiscordKeys.EMOJI, - DiscordKeys.LABEL, - DiscordKeys.STYLE, - DiscordKeys.TYPE, - DiscordKeys.URL, -]); - -/** - * Utils Component Button Structure - * @category Utils - */ - export class ComponentButton extends Structure { - readonly _keys = keysComponentButton; - - customId?: null | string; - disabled?: boolean; - emoji?: null | ComponentEmojiData; - label?: null | string; - style: MessageComponentButtonStyles = MessageComponentButtonStyles.PRIMARY; - type = MessageComponentTypes.BUTTON; - url?: null | string; - - constructor(data: ComponentData = {}) { - super(); - if (DetritusKeys[DiscordKeys.CUSTOM_ID] in data) { - (data as any)[DiscordKeys.CUSTOM_ID] = (data as any)[DetritusKeys[DiscordKeys.CUSTOM_ID]]; - } - this.merge(data); - this.type = MessageComponentTypes.BUTTON; - } - - setCustomId(customId: null | string): this { - this.merge({custom_id: customId}); - return this; - } - - setDisabled(disabled: boolean): this { - this.merge({disabled}); - return this; - } - - setEmoji(emoji: null | ComponentEmojiData): this { - this.merge({emoji}); - return this; - } - - setLabel(label: null | string): this { - this.merge({label}); - return this; - } - - setStyle(style: MessageComponentButtonStyles): this { - this.merge({style}); - return this; - } - - setUrl(url: null | string): this { - this.merge({url}); - if (url) { - this.setStyle(MessageComponentButtonStyles.LINK); - } - return this; - } - - mergeValue(key: string, value: any): void { - switch (key) { - case DiscordKeys.EMOJI: { - if (value instanceof Emoji) { - value = {animated: value.animated, id: value.id, name: value.name}; - } else if (typeof(value) === 'string') { - const { matches } = discordRegex(DiscordRegexNames.EMOJI, value); - if (matches.length) { - value = matches[0]; - } else { - value = {name: value}; - } - } - }; break; - } - return super.mergeValue(key, value); - } - - toJSON(): RequestTypes.RawChannelMessageComponent { - const data = super.toJSON() as any; - if (data.emoji instanceof Emoji) { - data.emoji = {animated: data.emoji.animated, id: data.emoji.id, name: data.emoji.name}; - } - return data; - } -} - - -const keysComponentSelectMenu = new BaseSet([ - DiscordKeys.CUSTOM_ID, - DiscordKeys.MAX_VALUES, - DiscordKeys.MIN_VALUES, - DiscordKeys.OPTIONS, - DiscordKeys.PLACEHOLDER, - DiscordKeys.TYPE, -]); - -/** - * Utils Component Select Menu Structure - * @category Utils - */ - export class ComponentSelectMenu extends Structure { - readonly _keys = keysComponentSelectMenu; - - customId: string = ''; - maxValues?: null | number; - minValues?: null | number; - options: Array = []; - placeholder?: null | string; - type = MessageComponentTypes.SELECT_MENU; - - constructor(data: ComponentData = {}) { - super(); - Object.assign(data, { - [DiscordKeys.CUSTOM_ID]: (data as any)[DetritusKeys[DiscordKeys.CUSTOM_ID]] || (data as any)[DiscordKeys.CUSTOM_ID], - [DiscordKeys.MAX_VALUES]: (data as any)[DetritusKeys[DiscordKeys.MAX_VALUES]] || (data as any)[DiscordKeys.MAX_VALUES], - [DiscordKeys.MIN_VALUES]: (data as any)[DetritusKeys[DiscordKeys.MIN_VALUES]] || (data as any)[DiscordKeys.MIN_VALUES], - }); - this.merge(data); - this.type = MessageComponentTypes.SELECT_MENU; - } - - addOption(option: ComponentSelectMenuOption): this { - this.options.push(option); - return this; - } - - createOption(data: ComponentSelectMenuOptionData = {}): ComponentSelectMenuOption { - const option = new ComponentSelectMenuOption(data); - this.addOption(option); - return option; - } - - setCustomId(customId: string): this { - this.merge({custom_id: customId}); - return this; - } - - setMaxValues(maxValues: null | number): this { - this.merge({max_values: maxValues}); - return this; - } - - setMinValues(minValues: null | number): this { - this.merge({min_values: minValues}); - return this; - } - - setPlaceholder(placeholder: null | string): this { - this.merge({placeholder}); - return this; - } - - mergeValue(key: string, value: any): void { - switch (key) { - case DiscordKeys.OPTIONS: { - this.options.length = 0; - for (let raw of value) { - const option = new ComponentSelectMenuOption(raw); - this.options.push(option); - } - }; return; - } - return super.mergeValue(key, value); - } - - toJSON(): RequestTypes.RawChannelMessageComponent { - return super.toJSON() as RequestTypes.RawChannelMessageComponent; - } -} - - -const keysComponentSelectMenuOption = new BaseSet([ - DiscordKeys.DEFAULT, - DiscordKeys.DESCRIPTION, - DiscordKeys.EMOJI, - DiscordKeys.LABEL, - DiscordKeys.VALUE, -]); - -/** - * Utils Component Select Menu Option Structure - * @category Utils - */ - export class ComponentSelectMenuOption extends Structure { - readonly _keys = keysComponentSelectMenuOption; - - default?: boolean; - description?: null | string; - emoji?: null | ComponentEmojiData; - label: string = ''; - value: string = ''; - - constructor(data: ComponentSelectMenuOptionData = {}) { - super(); - this.merge(data); - } - - setDefault(isDefault: boolean): this { - this.merge({default: isDefault}); - return this; - } - - setDescription(description: null | string): this { - this.merge({description}); - return this; - } - - setEmoji(emoji: null | ComponentEmojiData): this { - this.merge({emoji}); - return this; - } - - setLabel(label: string): this { - this.merge({label}); - return this; - } - - setValue(value: string): this { - this.merge({value}); - return this; - } - - mergeValue(key: string, value: any): void { - switch (key) { - case DiscordKeys.EMOJI: { - if (value instanceof Emoji) { - value = {animated: value.animated, id: value.id, name: value.name}; - } else if (typeof(value) === 'string') { - const { matches } = discordRegex(DiscordRegexNames.EMOJI, value); - if (matches.length) { - value = matches[0]; - } else { - value = {name: value}; - } - } - }; break; - } - return super.mergeValue(key, value); - } - - toJSON(): RequestTypes.RawChannelMessageComponentSelectMenuOption { - const data = super.toJSON() as any; - if (data.emoji instanceof Emoji) { - data.emoji = {animated: data.emoji.animated, id: data.emoji.id, name: data.emoji.name}; - } - return data; - } -} diff --git a/src/utils/components/actionbase.ts b/src/utils/components/actionbase.ts new file mode 100644 index 00000000..59b5a29a --- /dev/null +++ b/src/utils/components/actionbase.ts @@ -0,0 +1,68 @@ +import { RequestTypes } from 'detritus-client-rest'; +import { Snowflake } from 'detritus-utils'; + +import { BaseSet } from '../../collections/baseset'; +import { DetritusKeys, DiscordKeys, MessageComponentTypes } from '../../constants'; +import { Structure } from '../../structures/basestructure'; +import { Emoji } from '../../structures/emoji'; + +import { ComponentRun } from './components'; +import { ComponentContext } from './context'; +import { ComponentSelectMenuOptionData } from './selectmenu'; + + +export type ComponentEmojiData = {animated?: boolean, id?: null | string, name: string} | string | Emoji; + +export interface ComponentActionData { + custom_id?: string, + customId?: string, + disabled?: boolean, + emoji?: ComponentEmojiData, + label?: string, + max_values?: number, + maxValues?: number, + min_values?: number, + minValues?: number, + options?: Array, + placeholder?: string, + style?: number, + type?: number, + url?: string, + + run?: ComponentRun, +} + +const keysComponentActionBase = new BaseSet([ + DiscordKeys.CUSTOM_ID, + DiscordKeys.TYPE, +]); + +export class ComponentActionBase extends Structure { + readonly _keys = keysComponentActionBase; + _customIdEncoded?: null | string; + + customId?: null | string; + type: MessageComponentTypes = MessageComponentTypes.BUTTON; + + run?(context: ComponentContext): Promise | any; + + constructor(data: ComponentActionData = {}) { + super(); + if (DetritusKeys[DiscordKeys.CUSTOM_ID] in data) { + (data as any)[DiscordKeys.CUSTOM_ID] = (data as any)[DetritusKeys[DiscordKeys.CUSTOM_ID]]; + } + this.run = data.run || this.run; + if (typeof(this.run) === 'function' && !(data as any)[DiscordKeys.CUSTOM_ID]) { + (data as any)[DiscordKeys.CUSTOM_ID] = Snowflake.generate().id; + } + this.merge(data); + } + + toJSON(): RequestTypes.RawChannelMessageComponent { + const data = super.toJSON() as any; + if (data.emoji instanceof Emoji) { + data.emoji = {animated: data.emoji.animated, id: data.emoji.id, name: data.emoji.name}; + } + return data; + } +} diff --git a/src/utils/components/actionrow.ts b/src/utils/components/actionrow.ts new file mode 100644 index 00000000..7c4cdd74 --- /dev/null +++ b/src/utils/components/actionrow.ts @@ -0,0 +1,136 @@ +import { RequestTypes } from 'detritus-client-rest'; + +import { BaseSet } from '../../collections/baseset'; +import { + DiscordKeys, + MessageComponentTypes, + MAX_ACTION_ROW_BUTTONS, + MAX_ACTION_ROW_SELECT_MENUS, +} from '../../constants'; +import { Structure } from '../../structures/basestructure'; + +import { ComponentActionData } from './actionbase'; +import { ComponentButton } from './button'; +import { ComponentSelectMenu } from './selectmenu'; + + +export interface ComponentActionRowData { + components?: Array, + type?: number, +} + +const keysComponentActionRow = new BaseSet([ + DiscordKeys.COMPONENTS, + DiscordKeys.TYPE, +]); + +/** + * Utils Component Action Row Structure + * @category Utils + */ + export class ComponentActionRow extends Structure { + readonly _keys = keysComponentActionRow; + + components: Array = []; + type = MessageComponentTypes.ACTION_ROW; + + constructor(data: ComponentActionRowData = {}) { + super(); + this.merge(data); + this.type = MessageComponentTypes.ACTION_ROW; + } + + get hasButton(): boolean { + for (let component of this.components) { + if (component.type === MessageComponentTypes.BUTTON) { + return true; + } + } + return false; + } + + get hasRun(): boolean { + return this.components.some((component) => typeof(component.run) === 'function'); + } + + get hasSelectMenu(): boolean { + for (let component of this.components) { + if (component.type === MessageComponentTypes.SELECT_MENU) { + return true; + } + } + return false; + } + + get isEmpty(): boolean { + return !this.components.length; + } + + get isFull(): boolean { + if (this.hasSelectMenu) { + return MAX_ACTION_ROW_SELECT_MENUS <= this.components.length; + } else if (this.hasButton) { + return MAX_ACTION_ROW_BUTTONS <= this.components.length; + } + return false; + } + + addButton(data: ComponentButton | ComponentActionData = {}): this { + if (data instanceof ComponentButton) { + return this.addComponent(data); + } + return this.addComponent(new ComponentButton(data)); + } + + addComponent(component: ComponentButton | ComponentSelectMenu): this { + this.components.push(component); + return this; + } + + addSelectMenu(data: ComponentSelectMenu | ComponentActionData = {}): this { + if (data instanceof ComponentSelectMenu) { + return this.addComponent(data); + } + return this.addComponent(new ComponentSelectMenu(data)); + } + + createButton(data: ComponentActionData = {}): ComponentButton { + const component = new ComponentButton(data); + this.addComponent(component); + return component; + } + + createSelectMenu(data: ComponentActionData = {}): ComponentSelectMenu { + const component = new ComponentSelectMenu(data); + this.addComponent(component); + return component; + } + + mergeValue(key: string, value: any): void { + switch (key) { + case DiscordKeys.COMPONENTS: { + this.components.length = 0; + for (let raw of value) { + switch (raw.type) { + case MessageComponentTypes.BUTTON: { + const component = new ComponentButton(raw); + this.components.push(component); + }; break; + case MessageComponentTypes.SELECT_MENU: { + const component = new ComponentSelectMenu(raw); + this.components.push(component); + }; break; + default: { + throw new Error(`Unknown component type ${raw.type}`); + }; + } + } + }; return; + } + return super.mergeValue(key, value); + } + + toJSON(): RequestTypes.RawChannelMessageComponent { + return super.toJSON() as RequestTypes.RawChannelMessageComponent; + } +} diff --git a/src/utils/components/button.ts b/src/utils/components/button.ts new file mode 100644 index 00000000..fe2ed3ab --- /dev/null +++ b/src/utils/components/button.ts @@ -0,0 +1,95 @@ +import { BaseSet } from '../../collections/baseset'; +import { + DiscordKeys, + DiscordRegexNames, + MessageComponentButtonStyles, + MessageComponentTypes, +} from '../../constants'; +import { Emoji } from '../../structures/emoji'; +import { regex as discordRegex } from '../../utils'; + +import { ComponentActionBase, ComponentActionData, ComponentEmojiData } from './actionbase'; + + +const keysComponentButton = new BaseSet([ + DiscordKeys.CUSTOM_ID, + DiscordKeys.DISABLED, + DiscordKeys.EMOJI, + DiscordKeys.LABEL, + DiscordKeys.STYLE, + DiscordKeys.TYPE, + DiscordKeys.URL, +]); + +/** + * Utils Component Button Structure + * @category Utils + */ + export class ComponentButton extends ComponentActionBase { + readonly _keys = keysComponentButton; + + customId?: null | string; + disabled?: boolean; + emoji?: null | ComponentEmojiData; + label?: null | string; + style: MessageComponentButtonStyles = MessageComponentButtonStyles.PRIMARY; + type = MessageComponentTypes.BUTTON; + url?: null | string; + + constructor(data: ComponentActionData = {}) { + super(data); + this.merge(data); + this.type = MessageComponentTypes.BUTTON; + } + + setCustomId(customId: null | string): this { + this.merge({[DiscordKeys.CUSTOM_ID]: customId}); + return this; + } + + setDisabled(disabled: boolean): this { + this.merge({disabled}); + return this; + } + + setEmoji(emoji: null | ComponentEmojiData): this { + this.merge({emoji}); + return this; + } + + setLabel(label: null | string): this { + this.merge({label}); + return this; + } + + setStyle(style: MessageComponentButtonStyles): this { + this.merge({style}); + return this; + } + + setUrl(url: null | string): this { + this.merge({url}); + if (url) { + this.setStyle(MessageComponentButtonStyles.LINK); + } + return this; + } + + mergeValue(key: string, value: any): void { + switch (key) { + case DiscordKeys.EMOJI: { + if (value instanceof Emoji) { + value = {animated: value.animated, id: value.id, name: value.name}; + } else if (typeof(value) === 'string') { + const { matches } = discordRegex(DiscordRegexNames.EMOJI, value); + if (matches.length) { + value = matches[0]; + } else { + value = {name: value}; + } + } + }; break; + } + return super.mergeValue(key, value); + } +} diff --git a/src/utils/components/components.ts b/src/utils/components/components.ts new file mode 100644 index 00000000..1b53df72 --- /dev/null +++ b/src/utils/components/components.ts @@ -0,0 +1,130 @@ +import { RequestTypes } from 'detritus-client-rest'; +import { Timers } from 'detritus-utils'; + +import { BaseSet } from '../../collections/baseset'; +import { DiscordKeys, MessageComponentTypes } from '../../constants'; +import { Structure } from '../../structures/basestructure'; + +import { ComponentActionData } from './actionbase'; +import { ComponentActionRowData, ComponentActionRow } from './actionrow'; +import { ComponentButton } from './button'; +import { ComponentContext } from './context'; +import { ComponentSelectMenu } from './selectmenu'; + + +export type ComponentOnTimeout = () => Promise | any; +export type ComponentRun = (context: ComponentContext) => Promise | any; + + +export interface ComponentsOptions { + components?: Array, + id?: string, + timeout?: number, + + onTimeout?: ComponentOnTimeout, + run?: ComponentRun, +} + +const keysComponents = new BaseSet([ + DiscordKeys.COMPONENTS, + DiscordKeys.ID, + DiscordKeys.TIMEOUT, +]); + +/** + * Utils Components Structure + * @category Utils + */ +export class Components extends Structure { + readonly _keys = keysComponents; + _timeout?: Timers.Timeout; + + components: Array = []; + id?: string; + timeout: number = 10 * (60 * 1000); // 10 minutes + + onTimeout?(): Promise | any; + run?(context: ComponentContext): Promise | any; + + constructor(data: ComponentsOptions = {}) { + super(); + this.merge(data); + this.run = data.run || this.run; + this.onTimeout = data.onTimeout || this.onTimeout; + } + + addActionRow(data: ComponentActionRow | ComponentActionRowData = {}): this { + if (data instanceof ComponentActionRow) { + this.components.push(data); + } else { + this.createActionRow(data); + } + return this; + } + + addButton(data: ComponentButton | ComponentActionData = {}, inline = true): this { + let actionRow: ComponentActionRow; + if (inline) { + actionRow = this.components.find((row) => row.isEmpty || !row.isFull) || this.createActionRow(); + } else { + actionRow = this.createActionRow(); + } + actionRow.addButton(data); + return this; + } + + addSelectMenu(data: ComponentSelectMenu | ComponentActionData = {}): this { + const actionRow = this.createActionRow(); + actionRow.addSelectMenu(data); + return this; + } + + createActionRow(data: ComponentActionRowData = {}): ComponentActionRow { + const actionRow = new ComponentActionRow(data); + this.components.push(actionRow); + return actionRow; + } + + createButton(data: ComponentActionData = {}, inline = true): ComponentButton { + let actionRow: ComponentActionRow; + if (inline) { + actionRow = this.components.find((row) => row.isEmpty || !row.isFull) || this.createActionRow(); + } else { + actionRow = this.createActionRow(); + } + return actionRow.createButton(data); + } + + createSelectMenu(data: ComponentActionData = {}): ComponentSelectMenu { + const actionRow = this.createActionRow(); + return actionRow.createSelectMenu(data); + } + + mergeValue(key: string, value: any): void { + switch (key) { + case DiscordKeys.COMPONENTS: { + this.components.length = 0; + for (let raw of value) { + if (raw instanceof ComponentActionRow) { + this.components.push(raw); + } else { + switch (raw.type) { + case MessageComponentTypes.ACTION_ROW: { + const component = new ComponentActionRow(raw); + this.components.push(component); + }; break; + default: { + throw new Error(`Unknown component type ${raw.type}`); + }; + } + } + } + }; return; + } + return super.mergeValue(key, value); + } + + toJSON(): Array { + return this.components.map((component) => component.toJSON()); + } +} diff --git a/src/utils/components/context.ts b/src/utils/components/context.ts new file mode 100644 index 00000000..d5c76154 --- /dev/null +++ b/src/utils/components/context.ts @@ -0,0 +1,306 @@ +import { RequestTypes } from 'detritus-client-rest'; + +import { ShardClient } from '../../client'; +import { ClusterClient } from '../../clusterclient'; +import { ClusterProcessChild } from '../../cluster/processchild'; +import { + Interaction, + InteractionDataComponent, + InteractionEditOrRespond, + Message, +} from '../../structures'; + + +export class ComponentContext { + readonly client: ShardClient; + readonly interaction: Interaction; + + constructor(interaction: Interaction) { + this.interaction = interaction; + + this.client = interaction.client; + Object.defineProperties(this, { + client: {enumerable: false, writable: false}, + component: {enumerable: false, writable: false}, + interaction: {enumerable: false, writable: false}, + }); + } + + /* Generic Client Properties */ + get application() { + return this.client.application; + } + + get applicationId() { + return this.client.applicationId; + } + + get cluster(): ClusterClient | null { + return this.client.cluster; + } + + get commandClient() { + return this.client.commandClient; + } + + get gateway() { + return this.client.gateway; + } + + get interactionCommandClient() { + return this.client.interactionCommandClient; + } + + get manager(): ClusterProcessChild | null { + return (this.cluster) ? this.cluster.manager : null; + } + + get owners() { + return this.client.owners; + } + + get rest() { + return this.client.rest; + } + + get shardCount() { + return this.client.shardCount; + } + + get shardId() { + return this.client.shardId; + } + + /* Client Collections */ + get applications() { + return this.client.applications; + } + + get channels() { + return this.client.channels; + } + + get emojis() { + return this.client.emojis; + } + + get guilds() { + return this.client.guilds; + } + + get interactions() { + return this.client.interactions; + } + + get members() { + return this.client.members; + } + + get messages() { + return this.client.messages; + } + + get notes() { + return this.client.notes; + } + + get presences() { + return this.client.presences; + } + + get relationships() { + return this.client.relationships; + } + + get roles() { + return this.client.roles; + } + + get sessions() { + return this.client.sessions; + } + + get stageInstances() { + return this.client.stageInstances; + } + + get stickers() { + return this.client.stickers; + } + + get typings() { + return this.client.typings; + } + + get users() { + return this.client.users; + } + + get voiceCalls() { + return this.client.voiceCalls; + } + + get voiceConnections() { + return this.client.voiceConnections; + } + + get voiceStates() { + return this.client.voiceStates; + } + + /* Interaction Properties */ + get customId(): string { + return this.data.customId; + } + + get data(): InteractionDataComponent { + return this.interaction.data as InteractionDataComponent; + } + + get channel() { + return this.interaction.channel; + } + + get channelId(): string { + return this.interaction.channelId!; + } + + get guild() { + return this.interaction.guild; + } + + get guildId() { + return this.interaction.guildId; + } + + get id() { + return this.interaction.id; + } + + get inDm() { + return this.interaction.inDm; + } + + get interactionId() { + return this.interaction.id; + } + + get me() { + const guild = this.guild; + if (guild) { + return guild.me; + } + return null; + } + + get member() { + return this.interaction.member; + } + + get message(): Message { + return this.interaction.message!; + } + + get responded() { + return this.interaction.responded; + } + + get response() { + return this.interaction.response; + } + + get responseDeleted() { + return this.interaction.responseDeleted; + } + + get responseId() { + return this.interaction.responseId; + } + + get token() { + return this.interaction.token; + } + + get user() { + return this.interaction.user; + } + + get userId() { + return this.interaction.userId; + } + + get voiceChannel() { + const member = this.member; + if (member) { + return member.voiceChannel; + } + return null; + } + + get voiceConnection() { + return this.voiceConnections.get(this.guildId || this.channelId || ''); + } + + get voiceState() { + const member = this.member; + if (member) { + return member.voiceState; + } + return null; + } + + /* Functions */ + createMessage(options: RequestTypes.ExecuteWebhook | string = {}) { + return this.interaction.createMessage(options); + } + + createResponse( + options: RequestTypes.CreateInteractionResponse | number, + data?: RequestTypes.CreateInteractionResponseInnerPayload | string, + ) { + return this.interaction.createResponse(options, data); + } + + deleteMessage(messageId: string) { + return this.interaction.deleteMessage(messageId); + } + + deleteResponse() { + return this.interaction.deleteResponse(); + } + + editMessage(messageId: string, options: RequestTypes.EditWebhookTokenMessage = {}) { + return this.interaction.editMessage(messageId, options); + } + + editResponse(options: RequestTypes.EditWebhookTokenMessage = {}) { + return this.interaction.editResponse(options); + } + + editOrRespond(options: InteractionEditOrRespond | string = {}) { + return this.interaction.editOrRespond(options); + } + + fetchMessage(messageId: string) { + return this.interaction.fetchMessage(messageId); + } + + fetchResponse() { + return this.interaction.fetchResponse(); + } + + respond( + options: RequestTypes.CreateInteractionResponse | number, + data?: RequestTypes.CreateInteractionResponseInnerPayload | string, + ) { + return this.createResponse(options, data); + } + + toJSON() { + return this.interaction.toJSON(); + } + + toString() { + return `Interaction Context (${this.interaction.id})`; + } +} diff --git a/src/utils/components/index.ts b/src/utils/components/index.ts new file mode 100644 index 00000000..c26b0cf1 --- /dev/null +++ b/src/utils/components/index.ts @@ -0,0 +1,41 @@ +import { RequestTypes } from 'detritus-client-rest'; + +import { ComponentActionRow } from './actionrow'; +import { Components } from './components'; + +export * from './actionbase'; +export * from './actionrow'; +export * from './button'; +export * from './components'; +export * from './context'; +export * from './selectmenu'; + + +export interface CreateComponentListenerOrNone { + components?: Components | Array> | RequestTypes.toJSON>, +} + +// returns false when none of the components need to be hooked +export function createComponentListenerOrNone( + options?: CreateComponentListenerOrNone | string, + id?: string, +): Components | null | false { + if (!options || typeof(options) !== 'object' || !options.components) { + return null; + } + if (options.components instanceof Components) { + if (!options.components.components.length) { + return false; + } + options.components.id = id || options.components.id; + return options.components; + } else { + if (Array.isArray(options.components) && options.components.length) { + const actionRows = options.components.filter((component) => component instanceof ComponentActionRow) as Array; + if (actionRows.length && actionRows.some((row) => row.hasRun)) { + return new Components({components: actionRows, id}); + } + } + } + return false; +} diff --git a/src/utils/components/selectmenu.ts b/src/utils/components/selectmenu.ts new file mode 100644 index 00000000..5a1865e9 --- /dev/null +++ b/src/utils/components/selectmenu.ts @@ -0,0 +1,181 @@ +import { RequestTypes } from 'detritus-client-rest'; + +import { BaseSet } from '../../collections/baseset'; +import { + DetritusKeys, + DiscordKeys, + DiscordRegexNames, + MessageComponentTypes, +} from '../../constants'; +import { Structure } from '../../structures/basestructure'; +import { Emoji } from '../../structures/emoji'; +import { regex as discordRegex } from '../../utils'; + +import { ComponentActionBase, ComponentActionData, ComponentEmojiData } from './actionbase'; + + +export interface ComponentSelectMenuOptionData { + default?: boolean, + description?: string, + emoji?: ComponentEmojiData, + label?: string, + value?: string, +} + +const keysComponentSelectMenu = new BaseSet([ + DiscordKeys.CUSTOM_ID, + DiscordKeys.MAX_VALUES, + DiscordKeys.MIN_VALUES, + DiscordKeys.OPTIONS, + DiscordKeys.PLACEHOLDER, + DiscordKeys.TYPE, +]); + +/** + * Utils Component Select Menu Structure + * @category Utils + */ + export class ComponentSelectMenu extends ComponentActionBase { + readonly _keys = keysComponentSelectMenu; + + customId: string = ''; + maxValues?: null | number; + minValues?: null | number; + options: Array = []; + placeholder?: null | string; + type = MessageComponentTypes.SELECT_MENU; + + constructor(data: ComponentActionData = {}) { + super(); + Object.assign(data, { + [DiscordKeys.CUSTOM_ID]: (data as any)[DetritusKeys[DiscordKeys.CUSTOM_ID]] || (data as any)[DiscordKeys.CUSTOM_ID], + [DiscordKeys.MAX_VALUES]: (data as any)[DetritusKeys[DiscordKeys.MAX_VALUES]] || (data as any)[DiscordKeys.MAX_VALUES], + [DiscordKeys.MIN_VALUES]: (data as any)[DetritusKeys[DiscordKeys.MIN_VALUES]] || (data as any)[DiscordKeys.MIN_VALUES], + }); + this.merge(data); + this.type = MessageComponentTypes.SELECT_MENU; + } + + addOption(option: ComponentSelectMenuOption): this { + this.options.push(option); + return this; + } + + createOption(data: ComponentSelectMenuOptionData = {}): ComponentSelectMenuOption { + const option = new ComponentSelectMenuOption(data); + this.addOption(option); + return option; + } + + setCustomId(customId: string): this { + this.merge({custom_id: customId}); + return this; + } + + setMaxValues(maxValues: null | number): this { + this.merge({max_values: maxValues}); + return this; + } + + setMinValues(minValues: null | number): this { + this.merge({min_values: minValues}); + return this; + } + + setPlaceholder(placeholder: null | string): this { + this.merge({placeholder}); + return this; + } + + mergeValue(key: string, value: any): void { + switch (key) { + case DiscordKeys.OPTIONS: { + this.options.length = 0; + for (let raw of value) { + const option = new ComponentSelectMenuOption(raw); + this.options.push(option); + } + }; return; + } + return super.mergeValue(key, value); + } +} + + +const keysComponentSelectMenuOption = new BaseSet([ + DiscordKeys.DEFAULT, + DiscordKeys.DESCRIPTION, + DiscordKeys.EMOJI, + DiscordKeys.LABEL, + DiscordKeys.VALUE, +]); + +/** + * Utils Component Select Menu Option Structure + * @category Utils + */ + export class ComponentSelectMenuOption extends Structure { + readonly _keys = keysComponentSelectMenuOption; + + default?: boolean; + description?: null | string; + emoji?: null | ComponentEmojiData; + label: string = ''; + value: string = ''; + + constructor(data: ComponentSelectMenuOptionData = {}) { + super(); + this.merge(data); + } + + setDefault(isDefault: boolean): this { + this.merge({default: isDefault}); + return this; + } + + setDescription(description: null | string): this { + this.merge({description}); + return this; + } + + setEmoji(emoji: null | ComponentEmojiData): this { + this.merge({emoji}); + return this; + } + + setLabel(label: string): this { + this.merge({label}); + return this; + } + + setValue(value: string): this { + this.merge({value}); + return this; + } + + mergeValue(key: string, value: any): void { + switch (key) { + case DiscordKeys.EMOJI: { + if (value instanceof Emoji) { + value = {animated: value.animated, id: value.id, name: value.name}; + } else if (typeof(value) === 'string') { + const { matches } = discordRegex(DiscordRegexNames.EMOJI, value); + if (matches.length) { + value = matches[0]; + } else { + value = {name: value}; + } + } + }; break; + } + return super.mergeValue(key, value); + } + + toJSON(): RequestTypes.RawChannelMessageComponentSelectMenuOption { + const data = super.toJSON() as any; + if (data.emoji instanceof Emoji) { + data.emoji = {animated: data.emoji.animated, id: data.emoji.id, name: data.emoji.name}; + } + return data; + } +}