From 4ff3ea4a1bcb708973fbbbc84aaede1f7643e630 Mon Sep 17 00:00:00 2001 From: Jaw0r3k Date: Sun, 12 Nov 2023 17:32:41 +0100 Subject: [PATCH] feat: default select menu values (#9867) * feat: default select menu values * feat(Message): support * fix: fix crashes when an array is supplied and remove assertion * docs(transformResolved): `BaseChannel` is the correct type * refactor: prefer assignment * chore: export function again * fix(Util): fix circular dependency * refactor(MentionableSelectMenu): clone in method * docs: remove semicolon * feat(MentionableSelectMenu): add `addDefaultValues()` * refactor: reduce overhead * types: adjust `channel` --------- Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- .../selectMenu/ChannelSelectMenu.ts | 50 ++++++++++- .../selectMenu/MentionableSelectMenu.ts | 86 ++++++++++++++++++- .../components/selectMenu/RoleSelectMenu.ts | 47 +++++++++- .../components/selectMenu/UserSelectMenu.ts | 47 +++++++++- .../structures/ChatInputCommandInteraction.js | 3 +- .../src/structures/CommandInteraction.js | 57 ------------ .../ContextMenuCommandInteraction.js | 3 +- packages/discord.js/src/structures/Message.js | 15 +++- packages/discord.js/src/util/Util.js | 71 +++++++++++++++ packages/discord.js/typings/index.d.ts | 14 ++- 10 files changed, 320 insertions(+), 73 deletions(-) diff --git a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts index fe5b27b83ae1..35dfb9a53a0b 100644 --- a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts @@ -1,7 +1,12 @@ -import type { APIChannelSelectComponent, ChannelType } from 'discord-api-types/v10'; -import { ComponentType } from 'discord-api-types/v10'; -import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; -import { channelTypesValidator, customIdValidator } from '../Assertions.js'; +import { + type APIChannelSelectComponent, + type ChannelType, + type Snowflake, + ComponentType, + SelectMenuDefaultValueType, +} from 'discord-api-types/v10'; +import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; +import { channelTypesValidator, customIdValidator, optionsLengthValidator } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; /** @@ -59,6 +64,43 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { + const normalizedValues = normalizeArray(channels); + optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + this.data.default_values ??= []; + + this.data.default_values.push( + ...normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.Channel as const, + })), + ); + + return this; + } + + /** + * Sets default channels to this auto populated select menu. + * + * @param channels - The channels to set + */ + public setDefaultChannels(...channels: RestOrArray) { + const normalizedValues = normalizeArray(channels); + optionsLengthValidator.parse(normalizedValues.length); + + this.data.default_values = normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.Channel as const, + })); + + return this; + } + /** * {@inheritDoc BaseSelectMenuBuilder.toJSON} */ diff --git a/packages/builders/src/components/selectMenu/MentionableSelectMenu.ts b/packages/builders/src/components/selectMenu/MentionableSelectMenu.ts index a3a39a975fd8..b577822e9e0a 100644 --- a/packages/builders/src/components/selectMenu/MentionableSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/MentionableSelectMenu.ts @@ -1,5 +1,12 @@ -import type { APIMentionableSelectComponent } from 'discord-api-types/v10'; -import { ComponentType } from 'discord-api-types/v10'; +import { + type APIMentionableSelectComponent, + type APISelectMenuDefaultValue, + type Snowflake, + ComponentType, + SelectMenuDefaultValueType, +} from 'discord-api-types/v10'; +import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; +import { optionsLengthValidator } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; /** @@ -31,4 +38,79 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder) { super({ ...data, type: ComponentType.MentionableSelect }); } + + /** + * Adds default roles to this auto populated select menu. + * + * @param roles - The roles to add + */ + public addDefaultRoles(...roles: RestOrArray) { + const normalizedValues = normalizeArray(roles); + optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + this.data.default_values ??= []; + + this.data.default_values.push( + ...normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.Role as const, + })), + ); + + return this; + } + + /** + * Adds default users to this auto populated select menu. + * + * @param users - The users to add + */ + public addDefaultUsers(...users: RestOrArray) { + const normalizedValues = normalizeArray(users); + optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + this.data.default_values ??= []; + + this.data.default_values.push( + ...normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.User as const, + })), + ); + + return this; + } + + /** + * Adds default values to this auto populated select menu. + * + * @param values - The values to add + */ + public addDefaultValues( + ...values: RestOrArray< + | APISelectMenuDefaultValue + | APISelectMenuDefaultValue + > + ) { + const normalizedValues = normalizeArray(values); + optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + this.data.default_values ??= []; + this.data.default_values.push(...normalizedValues); + return this; + } + + /** + * Sets default values to this auto populated select menu. + * + * @param values - The values to set + */ + public setDefaultValues( + ...values: RestOrArray< + | APISelectMenuDefaultValue + | APISelectMenuDefaultValue + > + ) { + const normalizedValues = normalizeArray(values); + optionsLengthValidator.parse(normalizedValues.length); + this.data.default_values = normalizedValues.slice(); + return this; + } } diff --git a/packages/builders/src/components/selectMenu/RoleSelectMenu.ts b/packages/builders/src/components/selectMenu/RoleSelectMenu.ts index 2055b5f536cc..f3ae4ab01421 100644 --- a/packages/builders/src/components/selectMenu/RoleSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/RoleSelectMenu.ts @@ -1,5 +1,11 @@ -import type { APIRoleSelectComponent } from 'discord-api-types/v10'; -import { ComponentType } from 'discord-api-types/v10'; +import { + type APIRoleSelectComponent, + type Snowflake, + ComponentType, + SelectMenuDefaultValueType, +} from 'discord-api-types/v10'; +import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; +import { optionsLengthValidator } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; /** @@ -31,4 +37,41 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder) { super({ ...data, type: ComponentType.RoleSelect }); } + + /** + * Adds default roles to this auto populated select menu. + * + * @param roles - The roles to add + */ + public addDefaultRoles(...roles: RestOrArray) { + const normalizedValues = normalizeArray(roles); + optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + this.data.default_values ??= []; + + this.data.default_values.push( + ...normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.Role as const, + })), + ); + + return this; + } + + /** + * Sets default roles to this auto populated select menu. + * + * @param roles - The roles to set + */ + public setDefaultRoles(...roles: RestOrArray) { + const normalizedValues = normalizeArray(roles); + optionsLengthValidator.parse(normalizedValues.length); + + this.data.default_values = normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.Role as const, + })); + + return this; + } } diff --git a/packages/builders/src/components/selectMenu/UserSelectMenu.ts b/packages/builders/src/components/selectMenu/UserSelectMenu.ts index 09e184d65e99..1d32583ab095 100644 --- a/packages/builders/src/components/selectMenu/UserSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/UserSelectMenu.ts @@ -1,5 +1,11 @@ -import type { APIUserSelectComponent } from 'discord-api-types/v10'; -import { ComponentType } from 'discord-api-types/v10'; +import { + type APIUserSelectComponent, + type Snowflake, + ComponentType, + SelectMenuDefaultValueType, +} from 'discord-api-types/v10'; +import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; +import { optionsLengthValidator } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; /** @@ -31,4 +37,41 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder) { super({ ...data, type: ComponentType.UserSelect }); } + + /** + * Adds default users to this auto populated select menu. + * + * @param users - The users to add + */ + public addDefaultUsers(...users: RestOrArray) { + const normalizedValues = normalizeArray(users); + optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + this.data.default_values ??= []; + + this.data.default_values.push( + ...normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.User as const, + })), + ); + + return this; + } + + /** + * Sets default users to this auto populated select menu. + * + * @param users - The users to set + */ + public setDefaultUsers(...users: RestOrArray) { + const normalizedValues = normalizeArray(users); + optionsLengthValidator.parse(normalizedValues.length); + + this.data.default_values = normalizedValues.map((id) => ({ + id, + type: SelectMenuDefaultValueType.User as const, + })); + + return this; + } } diff --git a/packages/discord.js/src/structures/ChatInputCommandInteraction.js b/packages/discord.js/src/structures/ChatInputCommandInteraction.js index 4c4daf832a8b..17f6589366f8 100644 --- a/packages/discord.js/src/structures/ChatInputCommandInteraction.js +++ b/packages/discord.js/src/structures/ChatInputCommandInteraction.js @@ -2,6 +2,7 @@ const CommandInteraction = require('./CommandInteraction'); const CommandInteractionOptionResolver = require('./CommandInteractionOptionResolver'); +const { transformResolved } = require('../util/Util'); /** * Represents a command interaction. @@ -18,7 +19,7 @@ class ChatInputCommandInteraction extends CommandInteraction { this.options = new CommandInteractionOptionResolver( this.client, data.data.options?.map(option => this.transformOption(option, data.data.resolved)) ?? [], - this.transformResolved(data.data.resolved ?? {}), + transformResolved({ client: this.client, guild: this.guild, channel: this.channel }, data.data.resolved), ); } diff --git a/packages/discord.js/src/structures/CommandInteraction.js b/packages/discord.js/src/structures/CommandInteraction.js index f5cbb8337d9c..88086f9605b2 100644 --- a/packages/discord.js/src/structures/CommandInteraction.js +++ b/packages/discord.js/src/structures/CommandInteraction.js @@ -1,6 +1,5 @@ 'use strict'; -const { Collection } = require('@discordjs/collection'); const Attachment = require('./Attachment'); const BaseInteraction = require('./BaseInteraction'); const InteractionWebhook = require('./InteractionWebhook'); @@ -91,62 +90,6 @@ class CommandInteraction extends BaseInteraction { * @property {Collection} [attachments] The resolved attachments */ - /** - * Transforms the resolved received from the API. - * @param {APIInteractionDataResolved} resolved The received resolved objects - * @returns {CommandInteractionResolvedData} - * @private - */ - transformResolved({ members, users, channels, roles, messages, attachments }) { - const result = {}; - - if (members) { - result.members = new Collection(); - for (const [id, member] of Object.entries(members)) { - const user = users[id]; - result.members.set(id, this.guild?.members._add({ user, ...member }) ?? member); - } - } - - if (users) { - result.users = new Collection(); - for (const user of Object.values(users)) { - result.users.set(user.id, this.client.users._add(user)); - } - } - - if (roles) { - result.roles = new Collection(); - for (const role of Object.values(roles)) { - result.roles.set(role.id, this.guild?.roles._add(role) ?? role); - } - } - - if (channels) { - result.channels = new Collection(); - for (const channel of Object.values(channels)) { - result.channels.set(channel.id, this.client.channels._add(channel, this.guild) ?? channel); - } - } - - if (messages) { - result.messages = new Collection(); - for (const message of Object.values(messages)) { - result.messages.set(message.id, this.channel?.messages?._add(message) ?? message); - } - } - - if (attachments) { - result.attachments = new Collection(); - for (const attachment of Object.values(attachments)) { - const patched = new Attachment(attachment); - result.attachments.set(attachment.id, patched); - } - } - - return result; - } - /** * Represents an option of a received command interaction. * @typedef {Object} CommandInteractionOption diff --git a/packages/discord.js/src/structures/ContextMenuCommandInteraction.js b/packages/discord.js/src/structures/ContextMenuCommandInteraction.js index fc49ca56d3eb..7df06b68b35c 100644 --- a/packages/discord.js/src/structures/ContextMenuCommandInteraction.js +++ b/packages/discord.js/src/structures/ContextMenuCommandInteraction.js @@ -4,6 +4,7 @@ const { lazy } = require('@discordjs/util'); const { ApplicationCommandOptionType } = require('discord-api-types/v10'); const CommandInteraction = require('./CommandInteraction'); const CommandInteractionOptionResolver = require('./CommandInteractionOptionResolver'); +const { transformResolved } = require('../util/Util'); const getMessage = lazy(() => require('./Message').Message); @@ -21,7 +22,7 @@ class ContextMenuCommandInteraction extends CommandInteraction { this.options = new CommandInteractionOptionResolver( this.client, this.resolveContextMenuOptions(data.data), - this.transformResolved(data.data.resolved), + transformResolved({ client: this.client, guild: this.guild, channel: this.channel }, data.data.resolved), ); /** diff --git a/packages/discord.js/src/structures/Message.js b/packages/discord.js/src/structures/Message.js index 7f145b3b8436..679b34a8d5a9 100644 --- a/packages/discord.js/src/structures/Message.js +++ b/packages/discord.js/src/structures/Message.js @@ -25,7 +25,7 @@ const { createComponent } = require('../util/Components'); const { NonSystemMessageTypes, MaxBulkDeletableMessageAge, DeletableMessageTypes } = require('../util/Constants'); const MessageFlagsBitField = require('../util/MessageFlagsBitField'); const PermissionsBitField = require('../util/PermissionsBitField'); -const { cleanContent, resolvePartialEmoji } = require('../util/Util'); +const { cleanContent, resolvePartialEmoji, transformResolved } = require('../util/Util'); /** * Represents a message on Discord. @@ -223,6 +223,19 @@ class Message extends Base { this.roleSubscriptionData ??= null; } + if ('resolved' in data) { + /** + * Resolved data from auto-populated select menus. + * @typedef {Object} CommandInteractionResolvedData + */ + this.resolved = transformResolved( + { client: this.client, guild: this.guild, channel: this.channel }, + data.resolved, + ); + } else { + this.resolved ??= null; + } + // Discord sends null if the message has not been edited if (data.edited_timestamp) { /** diff --git a/packages/discord.js/src/util/Util.js b/packages/discord.js/src/util/Util.js index eeef8bab09f4..0f8ff0a3cee8 100644 --- a/packages/discord.js/src/util/Util.js +++ b/packages/discord.js/src/util/Util.js @@ -409,6 +409,75 @@ function parseWebhookURL(url) { }; } +/** + * Supportive data for interaction resolved data. + * @typedef {Object} SupportingInteractionResolvedData + * @property {Client} client The client + * @property {Guild} [guild] A guild + * @property {GuildTextBasedChannel} [channel] A channel + * @private + */ + +/** + * Transforms the resolved data received from the API. + * @param {SupportingInteractionResolvedData} supportingData Data to support the transformation + * @param {APIInteractionDataResolved} [data] The received resolved objects + * @returns {CommandInteractionResolvedData} + * @private + */ +function transformResolved( + { client, guild, channel }, + { members, users, channels, roles, messages, attachments } = {}, +) { + const result = {}; + + if (members) { + result.members = new Collection(); + for (const [id, member] of Object.entries(members)) { + const user = users[id]; + result.members.set(id, guild?.members._add({ user, ...member }) ?? member); + } + } + + if (users) { + result.users = new Collection(); + for (const user of Object.values(users)) { + result.users.set(user.id, client.users._add(user)); + } + } + + if (roles) { + result.roles = new Collection(); + for (const role of Object.values(roles)) { + result.roles.set(role.id, guild?.roles._add(role) ?? role); + } + } + + if (channels) { + result.channels = new Collection(); + for (const apiChannel of Object.values(channels)) { + result.channels.set(apiChannel.id, client.channels._add(apiChannel, guild) ?? apiChannel); + } + } + + if (messages) { + result.messages = new Collection(); + for (const message of Object.values(messages)) { + result.messages.set(message.id, channel?.messages?._add(message) ?? message); + } + } + + if (attachments) { + result.attachments = new Collection(); + for (const attachment of Object.values(attachments)) { + const patched = new Attachment(attachment); + result.attachments.set(attachment.id, patched); + } + } + + return result; +} + module.exports = { flatten, fetchRecommendedShardCount, @@ -426,7 +495,9 @@ module.exports = { cleanContent, cleanCodeBlockContent, parseWebhookURL, + transformResolved, }; // Fixes Circular +const Attachment = require('../structures/Attachment'); const GuildChannel = require('../structures/GuildChannel'); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 876cd8384a06..8265c709e702 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -586,9 +586,6 @@ export abstract class CommandInteraction e option: APIApplicationCommandOption, resolved: APIApplicationCommandInteractionData['resolved'], ): CommandInteractionOption; - private transformResolved( - resolved: APIApplicationCommandInteractionData['resolved'], - ): CommandInteractionResolvedData; } export class InteractionResponse { @@ -2037,6 +2034,7 @@ export class Message extends Base { public stickers: Collection; public position: number | null; public roleSubscriptionData: RoleSubscriptionData | null; + public resolved: CommandInteractionResolvedData | null; public system: boolean; public get thread(): AnyThreadChannel | null; public tts: boolean; @@ -3223,6 +3221,10 @@ export function setPosition( reason?: string, ): Promise<{ id: Snowflake; position: number }[]>; export function parseWebhookURL(url: string): WebhookClientDataIdWithToken | null; +export function transformResolved( + supportingData: SupportingInteractionResolvedData, + data?: APIApplicationCommandInteractionData['resolved'], +): CommandInteractionResolvedData; export interface MappedComponentBuilderTypes { [ComponentType.Button]: ButtonBuilder; @@ -6363,6 +6365,12 @@ export interface StageInstanceEditOptions { privacyLevel?: StageInstancePrivacyLevel; } +export interface SupportingInteractionResolvedData { + client: Client; + guild?: Guild; + channel?: GuildTextBasedChannel; +} + export type SweeperKey = keyof SweeperDefinitions; export type CollectionSweepFilter = (value: V, key: K, collection: Collection) => boolean;