diff --git a/packages/bot/src/desiredProperties.ts b/packages/bot/src/desiredProperties.ts index a46252626..2b3b53bbc 100644 --- a/packages/bot/src/desiredProperties.ts +++ b/packages/bot/src/desiredProperties.ts @@ -338,6 +338,8 @@ export function createDesiredPropertiesObject channel: TransformerFunction collectibles: TransformerFunction - component: TransformerFunction + component: TransformerFunction< + TProps, + TBehavior, + DiscordMessageComponent | DiscordMessageComponentFromModalInteractionResponse, + Component, + {}, + 'unchanged' + > defaultReactionEmoji: TransformerFunction embed: TransformerFunction emoji: TransformerFunction @@ -352,7 +360,7 @@ export type Transformers, payload: ApplicationCommandOption) => DiscordApplicationCommandOption applicationCommandOptionChoice: (bot: Bot, payload: ApplicationCommandOptionChoice) => DiscordApplicationCommandOptionChoice attachment: (bot: Bot, payload: SetupDesiredProps) => DiscordAttachment - component: (bot: Bot, payload: Component) => DiscordMessageComponent + component: (bot: Bot, payload: Component) => DiscordMessageComponent | DiscordMessageComponentFromModalInteractionResponse embed: (bot: Bot, payload: Embed) => DiscordEmbed mediaGalleryItem: (bot: Bot, payload: MediaGalleryItem) => DiscordMediaGalleryItem member: (bot: Bot, payload: SetupDesiredProps) => DiscordMember diff --git a/packages/bot/src/transformers/component.ts b/packages/bot/src/transformers/component.ts index adb4a22c4..3d01eb130 100644 --- a/packages/bot/src/transformers/component.ts +++ b/packages/bot/src/transformers/component.ts @@ -3,14 +3,18 @@ import { type DiscordButtonComponent, type DiscordContainerComponent, type DiscordFileComponent, + type DiscordLabelComponent, type DiscordMediaGalleryComponent, type DiscordMediaGalleryItem, type DiscordMessageComponent, + type DiscordMessageComponentFromModalInteractionResponse, type DiscordSectionComponent, type DiscordSelectMenuComponent, type DiscordSeparatorComponent, + type DiscordStringSelectInteractionResponseFromModal, type DiscordTextDisplayComponent, type DiscordTextInputComponent, + type DiscordTextInputInteractionResponse, type DiscordThumbnailComponent, type DiscordUnfurledMediaItem, MessageComponentTypes, @@ -18,7 +22,7 @@ import { import type { Bot } from '../bot.js' import type { Component, MediaGalleryItem, UnfurledMediaItem } from './types.js' -export function transformComponent(bot: Bot, payload: DiscordMessageComponent): Component { +export function transformComponent(bot: Bot, payload: DiscordMessageComponent | DiscordMessageComponentFromModalInteractionResponse): Component { let component: Component // This switch is exhaustive, so we dont need the default case and TS does not error out for the un-initialized component variable @@ -60,6 +64,9 @@ export function transformComponent(bot: Bot, payload: DiscordMessageComponent): case MessageComponentTypes.TextDisplay: component = transformTextDisplayComponent(bot, payload) break + case MessageComponentTypes.Label: + component = transformLabelComponent(bot, payload) + break } return bot.transformers.customizers.component(bot, payload, component) @@ -131,55 +138,65 @@ function transformButtonComponent(bot: Bot, payload: DiscordButtonComponent): Co return button } -function transformInputTextComponent(bot: Bot, payload: DiscordTextInputComponent): Component { +function transformInputTextComponent(bot: Bot, payload: DiscordTextInputComponent | DiscordTextInputInteractionResponse): Component { const props = bot.transformers.desiredProperties.component const input = {} as Component if (props.type && payload.type) input.type = payload.type if (props.id && payload.id) input.id = payload.id - if (props.style && payload.style) input.style = payload.style - if (props.required && payload.required) input.required = payload.required - if (props.customId && payload.custom_id) input.customId = payload.custom_id - if (props.label && payload.label) input.label = payload.label - if (props.placeholder && payload.placeholder) input.placeholder = payload.placeholder - if (props.minLength && payload.min_length) input.minLength = payload.min_length - if (props.maxLength && payload.max_length) input.maxLength = payload.max_length if (props.value && payload.value) input.value = payload.value + // Check if it is the component or the response + if ('style' in payload) { + if (props.style && payload.style) input.style = payload.style + if (props.required && payload.required) input.required = payload.required + if (props.customId && payload.custom_id) input.customId = payload.custom_id + if (props.label && payload.label) input.label = payload.label + if (props.placeholder && payload.placeholder) input.placeholder = payload.placeholder + if (props.minLength && payload.min_length) input.minLength = payload.min_length + if (props.maxLength && payload.max_length) input.maxLength = payload.max_length + } + return input } -function transformSelectMenuComponent(bot: Bot, payload: DiscordSelectMenuComponent): Component { +function transformSelectMenuComponent(bot: Bot, payload: DiscordSelectMenuComponent | DiscordStringSelectInteractionResponseFromModal): Component { const props = bot.transformers.desiredProperties.component const select = {} as Component if (props.type && payload.type) select.type = payload.type if (props.id && payload.id) select.id = payload.id if (props.customId && payload.custom_id) select.customId = payload.custom_id - if (props.placeholder && payload.placeholder) select.placeholder = payload.placeholder - if (props.minValues && payload.min_values) select.minValues = payload.min_values - if (props.maxValues && payload.max_values) select.maxValues = payload.max_values - if (props.defaultValues && payload.default_values) - select.defaultValues = payload.default_values.map((defaultValue) => ({ - id: bot.transformers.snowflake(defaultValue.id), - type: defaultValue.type, - })) - if (props.channelTypes && payload.channel_types) select.channelTypes = payload.channel_types - if (props.options && payload.options) - select.options = payload.options.map((option) => ({ - label: option.label, - value: option.value, - description: option.description, - emoji: option.emoji - ? { - id: option.emoji.id ? bot.transformers.snowflake(option.emoji.id) : undefined, - name: option.emoji.name ?? undefined, - animated: option.emoji.animated, - } - : undefined, - default: option.default, - })) - if (props.disabled && payload.disabled) select.disabled = payload.disabled + + // Check if this is the string select response + if ('values' in payload) { + if (props.values && payload.values) select.values = payload.values + } else { + if (props.placeholder && payload.placeholder) select.placeholder = payload.placeholder + if (props.minValues && payload.min_values) select.minValues = payload.min_values + if (props.maxValues && payload.max_values) select.maxValues = payload.max_values + if (props.defaultValues && payload.default_values) + select.defaultValues = payload.default_values.map((defaultValue) => ({ + id: bot.transformers.snowflake(defaultValue.id), + type: defaultValue.type, + })) + if (props.channelTypes && payload.channel_types) select.channelTypes = payload.channel_types + if (props.options && payload.options) + select.options = payload.options.map((option) => ({ + label: option.label, + value: option.value, + description: option.description, + emoji: option.emoji + ? { + id: option.emoji.id ? bot.transformers.snowflake(option.emoji.id) : undefined, + name: option.emoji.name ?? undefined, + animated: option.emoji.animated, + } + : undefined, + default: option.default, + })) + if (props.disabled && payload.disabled) select.disabled = payload.disabled + } return select } @@ -256,3 +273,16 @@ function transformSeparatorComponent(bot: Bot, payload: DiscordSeparatorComponen return separator } + +function transformLabelComponent(bot: Bot, payload: DiscordLabelComponent): Component { + const props = bot.transformers.desiredProperties.component + const label = {} as Component + + if (props.type && payload.type) label.type = payload.type + if (props.id && payload.id) label.id = payload.id + if (props.label && payload.label) label.label = payload.label + if (props.description && payload.description) label.description = payload.description + if (props.component && payload.component) label.component = bot.transformers.component(bot, payload.component) + + return label +} diff --git a/packages/bot/src/transformers/reverse/component.ts b/packages/bot/src/transformers/reverse/component.ts index 9b8662013..be7ccbe21 100644 --- a/packages/bot/src/transformers/reverse/component.ts +++ b/packages/bot/src/transformers/reverse/component.ts @@ -4,13 +4,17 @@ import { type DiscordButtonComponent, type DiscordContainerComponent, type DiscordFileComponent, + type DiscordLabelComponent, type DiscordMediaGalleryComponent, type DiscordMediaGalleryItem, type DiscordMessageComponent, + type DiscordMessageComponentFromModalInteractionResponse, type DiscordSectionComponent, type DiscordSelectMenuComponent, + type DiscordStringSelectInteractionResponseFromModal, type DiscordTextDisplayComponent, type DiscordTextInputComponent, + type DiscordTextInputInteractionResponse, type DiscordThumbnailComponent, type DiscordUnfurledMediaItem, MessageComponentTypes, @@ -19,7 +23,10 @@ import { import type { Bot } from '../../bot.js' import type { Component, MediaGalleryItem, UnfurledMediaItem } from '../types.js' -export function transformComponentToDiscordComponent(bot: Bot, payload: Component): DiscordMessageComponent { +export function transformComponentToDiscordComponent( + bot: Bot, + payload: Component, +): DiscordMessageComponent | DiscordMessageComponentFromModalInteractionResponse { // This switch should include all cases switch (payload.type) { case MessageComponentTypes.ActionRow: @@ -44,6 +51,8 @@ export function transformComponentToDiscordComponent(bot: Bot, payload: Componen return transformMediaGalleryComponent(bot, payload) case MessageComponentTypes.Thumbnail: return transformThumbnailComponent(bot, payload) + case MessageComponentTypes.Label: + return transformLabelComponent(bot, payload) case MessageComponentTypes.Separator: case MessageComponentTypes.TextDisplay: // As of now they are compatible @@ -111,7 +120,7 @@ function transformButtonComponent(bot: Bot, payload: Component): DiscordButtonCo } } -function transformInputTextComponent(_bot: Bot, payload: Component): DiscordTextInputComponent { +function transformInputTextComponent(_bot: Bot, payload: Component): DiscordTextInputComponent | DiscordTextInputInteractionResponse { // Since Component is a merge of all components, some casts are necessary return { type: MessageComponentTypes.TextInput, @@ -127,7 +136,16 @@ function transformInputTextComponent(_bot: Bot, payload: Component): DiscordText } } -function transformSelectMenuComponent(bot: Bot, payload: Component): DiscordSelectMenuComponent { +function transformSelectMenuComponent(bot: Bot, payload: Component): DiscordSelectMenuComponent | DiscordStringSelectInteractionResponseFromModal { + if (payload.values) { + return { + type: MessageComponentTypes.StringSelect, + values: payload.values, + custom_id: payload.customId!, + id: payload.id!, + } + } + return { type: payload.type as DiscordSelectMenuComponent['type'], id: payload.id, @@ -154,6 +172,7 @@ function transformSelectMenuComponent(bot: Bot, payload: Component): DiscordSele default: option.default, })), placeholder: payload.placeholder, + required: payload.required, } } @@ -194,3 +213,13 @@ function transformThumbnailComponent(bot: Bot, payload: Component): DiscordThumb spoiler: payload.spoiler, } } + +function transformLabelComponent(bot: Bot, payload: Component): DiscordLabelComponent { + return { + type: MessageComponentTypes.Label, + id: payload.id, + label: payload.label!, + description: payload.description, + component: bot.transformers.reverse.component(bot, payload.component!) as DiscordLabelComponent['component'], + } +} diff --git a/packages/bot/src/transformers/types.ts b/packages/bot/src/transformers/types.ts index 6f3bb9f12..7c48406dd 100644 --- a/packages/bot/src/transformers/types.ts +++ b/packages/bot/src/transformers/types.ts @@ -572,6 +572,10 @@ export interface Component { name?: string /** The size of the file in bytes. This field is ignored and provided by the API as part of the response */ size?: number + /** The component within the label */ + component?: Component + /** The text of the selected options */ + values?: string[] } export interface UnfurledMediaItem { diff --git a/packages/types/src/discord/components.ts b/packages/types/src/discord/components.ts index 14cfdad3c..f840f42e4 100644 --- a/packages/types/src/discord/components.ts +++ b/packages/types/src/discord/components.ts @@ -1,7 +1,9 @@ /** Types for: https://discord.com/developers/docs/components/reference */ +import type { Require } from '../shared.js' import type { ChannelTypes } from './channel.js' import type { DiscordEmoji } from './emoji.js' +import type { DiscordInteractionDataResolved } from './interactions.js' /** https://discord.com/developers/docs/components/reference#component-object-component-types */ export enum MessageComponentTypes { @@ -35,6 +37,8 @@ export enum MessageComponentTypes { Separator, /** Container that visually groups a set of components */ Container = 17, + /** Container associating a label and description with a component */ + Label, } export type DiscordMessageComponents = DiscordMessageComponent[] @@ -50,6 +54,26 @@ export type DiscordMessageComponent = | DiscordSeparatorComponent | DiscordContainerComponent | DiscordFileComponent + | DiscordLabelComponent + +export type DiscordMessageComponentResponse = + | DiscordTextInputInteractionResponse + | DiscordRoleSelectInteractionResponse + | DiscordUserSelectInteractionResponse + | DiscordStringSelectInteractionResponse + | DiscordChannelSelectInteractionResponse + | DiscordMentionableSelectInteractionResponse + +export type DiscordMessageComponentFromModalInteractionResponse = + | DiscordTextInputInteractionResponse + | DiscordStringSelectInteractionResponseFromModal + +export type DiscordMessageComponentFromMessageComponentInteractionResponse = + | DiscordRoleSelectInteractionResponse + | DiscordUserSelectInteractionResponse + | DiscordStringSelectInteractionResponseFromMessageComponent + | DiscordChannelSelectInteractionResponse + | DiscordMentionableSelectInteractionResponse /** https://discord.com/developers/docs/components/reference#anatomy-of-a-component */ export interface DiscordBaseComponent { @@ -68,6 +92,9 @@ export interface DiscordActionRow extends DiscordBaseComponent { * * @remarks * Up to 5 button components, a single select component or a single text input component + * + * Using a {@link DiscordTextInputComponent} inside the Action Row is deprecated, + * use a {@link DiscordLabelComponent} for modals */ components: (DiscordButtonComponent | DiscordSelectMenuComponent | DiscordTextInputComponent)[] } @@ -161,9 +188,21 @@ export interface DiscordSelectMenuComponent extends DiscordBaseComponent { min_values?: number /** The maximum number of items that can be selected. Default 1. Max 25. */ max_values?: number + /** + * Whether this component is required to be filled + * + * @remarks + * This value is only valid for string select menus in modals + * + * @default true + */ + required?: boolean /** * Whether select menu is disabled * + * @remarks + * This value cannot be set for select menus in modals + * * @default false */ disabled?: boolean @@ -192,6 +231,34 @@ export interface DiscordSelectOption { default?: boolean } +/** https://discord.com/developers/docs/components/reference#string-select-strings-select-interaction-response-structure */ +export interface DiscordStringSelectInteractionResponse { + /** + * @remarks + * This is only returned for interaction responses from modals + */ + type?: MessageComponentTypes.StringSelect + /** + * @remarks + * This is only returned for interaction responses from message interactions + */ + component_type?: MessageComponentTypes.StringSelect + /** 32 bit integer used as an optional identifier for component */ + id: number + /** The custom id for the string select menu */ + custom_id: string + /** The text of the selected options */ + values: string[] +} + +/** https://discord.com/developers/docs/components/reference#string-select-string-select-interaction-response-structure */ +export type DiscordStringSelectInteractionResponseFromModal = Require, 'type'> +/** https://discord.com/developers/docs/components/reference#string-select-string-select-interaction-response-structure */ +export type DiscordStringSelectInteractionResponseFromMessageComponent = Require< + Omit, + 'component_type' +> + /** https://discord.com/developers/docs/components/reference#text-input-text-input-structure */ export interface DiscordTextInputComponent extends DiscordBaseComponent { type: MessageComponentTypes.TextInput @@ -200,8 +267,15 @@ export interface DiscordTextInputComponent extends DiscordBaseComponent { custom_id: string /** The style of the InputText */ style: TextStyles - /** The label of the InputText (max 45 characters) */ - label: string + /** + * The label of the InputText. + * + * @remarks + * Maximum 45 characters + * + * @deprecated Use the `label` and `description` from the {@link DiscordLabelComponent} + */ + label?: string /** The minimum length of the text the user has to provide */ min_length?: number /** The maximum length of the text the user has to provide */ @@ -222,6 +296,17 @@ export enum TextStyles { Paragraph = 2, } +/** https://discord.com/developers/docs/components/reference#text-input-text-input-interaction-response-structure */ +export interface DiscordTextInputInteractionResponse { + type: MessageComponentTypes.TextInput + /** 32 bit integer used as an optional identifier for component */ + id: number + /** The custom id for the text input */ + custom_id: string + /** The user's input text */ + value: string +} + /** https://discord.com/developers/docs/components/reference#user-select-select-default-value-structure */ export interface DiscordSelectMenuDefaultValue { /** ID of a user, role, or channel */ @@ -230,6 +315,58 @@ export interface DiscordSelectMenuDefaultValue { type: 'user' | 'role' | 'channel' } +/** https://discord.com/developers/docs/components/reference#user-select-user-select-interaction-response-structure */ +export interface DiscordUserSelectInteractionResponse { + component_type: MessageComponentTypes.UserSelect + /** 32 bit integer used as an optional identifier for component */ + id: number + /** The custom id for the user select */ + custom_id: string + /** Resolved entities from selected options */ + resolved: DiscordInteractionDataResolved + /** IDs of the selected users */ + values: string[] +} + +/** https://discord.com/developers/docs/components/reference#role-select-role-select-interaction-response-structure */ +export interface DiscordRoleSelectInteractionResponse { + component_type: MessageComponentTypes.RoleSelect + /** 32 bit integer used as an optional identifier for component */ + id: number + /** The custom id for the role select */ + custom_id: string + /** Resolved entities from selected options */ + resolved: DiscordInteractionDataResolved + /** IDs of the selected roles */ + values: string[] +} + +/** https://discord.com/developers/docs/components/reference#mentionable-select-mentionable-select-interaction-response-structure */ +export interface DiscordMentionableSelectInteractionResponse { + component_type: MessageComponentTypes.MentionableSelect + /** 32 bit integer used as an optional identifier for component */ + id: number + /** The custom id for the mentionable select */ + custom_id: string + /** Resolved entities from selected options */ + resolved: DiscordInteractionDataResolved + /** IDs of the selected mentionables */ + values: string[] +} + +/** https://discord.com/developers/docs/components/reference#channel-select-channel-select-interaction-response-structure */ +export interface DiscordChannelSelectInteractionResponse { + component_type: MessageComponentTypes.ChannelSelect + /** 32 bit integer used as an optional identifier for component */ + id: number + /** The custom id for the channel select */ + custom_id: string + /** Resolved entities from selected options */ + resolved: DiscordInteractionDataResolved + /** IDs of the selected channels */ + values: string[] +} + /** https://discord.com/developers/docs/components/reference#section-section-structure */ export interface DiscordSectionComponent extends DiscordBaseComponent { type: MessageComponentTypes.Section @@ -326,6 +463,28 @@ export interface DiscordContainerComponent extends DiscordBaseComponent { spoiler?: boolean } +/** https://discord.com/developers/docs/components/reference#label-label-structure */ +export interface DiscordLabelComponent extends DiscordBaseComponent { + type: MessageComponentTypes.Label + + /** + * The label text + * + * @remarks + * Max 45 characters. + */ + label: string + /** + * An optional description text for the label + * + * @remarks + * Max 100 characters. + */ + description?: string + /** The component within the label */ + component: DiscordTextInputComponent | DiscordSelectMenuComponent +} + /** https://discord.com/developers/docs/components/reference#unfurled-media-item-structure */ export interface DiscordUnfurledMediaItem { /** Supports arbitrary urls and attachment:// references */ diff --git a/packages/types/src/discord/interactions.ts b/packages/types/src/discord/interactions.ts index 33fe97eaf..e2703e646 100644 --- a/packages/types/src/discord/interactions.ts +++ b/packages/types/src/discord/interactions.ts @@ -6,7 +6,7 @@ import type { DiscordApplicationIntegrationType } from './application.js' import type { ChannelTypes, DiscordChannel } from './channel.js' -import type { DiscordMessageComponents, MessageComponentTypes } from './components.js' +import type { DiscordMessageComponentFromModalInteractionResponse, MessageComponentTypes } from './components.js' import type { DiscordEntitlement } from './entitlement.js' import type { DiscordGuild, DiscordMember, DiscordMemberWithUser } from './guild.js' import type { DiscordAttachment, DiscordMessage } from './message.js' @@ -131,7 +131,7 @@ export interface DiscordInteractionData { // Modal Submit Data /** The components if its a Modal Submit interaction. */ - components?: DiscordMessageComponents + components?: DiscordMessageComponentFromModalInteractionResponse[] } /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-resolved-data-structure */ diff --git a/packages/types/src/discordeno/components.ts b/packages/types/src/discordeno/components.ts index 048c26d3d..e7a93e830 100644 --- a/packages/types/src/discordeno/components.ts +++ b/packages/types/src/discordeno/components.ts @@ -28,6 +28,7 @@ export type MessageComponent = | SeparatorComponent | ContainerComponent | FileComponent + | LabelComponent /** https://discord.com/developers/docs/components/reference#anatomy-of-a-component */ export interface BaseComponent { @@ -46,6 +47,9 @@ export interface ActionRow extends BaseComponent { * * @remarks * Up to 5 button components, a single select component or a single text input component + * + * Using a {@link TextInputComponent} inside the Action Row is deprecated, + * use a {@link LabelComponent} for modals */ components: ( | ButtonComponent @@ -106,7 +110,23 @@ export interface StringSelectComponent extends BaseComponent { minValues?: number /** The maximum number of items that can be selected. Default 1. Max 25. */ maxValues?: number - /** Whether or not this select is disabled */ + /** + * Whether this component is required to be filled + * + * @remarks + * This value is only valid for string select menus in modals + * + * @default true + */ + required?: boolean + /** + * Whether select menu is disabled + * + * @remarks + * This value cannot be set for select menus in modals + * + * @default false + */ disabled?: boolean } @@ -140,8 +160,15 @@ export interface TextInputComponent extends BaseComponent { customId: string /** The style of the InputText */ style: TextStyles - /** The label of the InputText. Maximum 45 characters */ - label: string + /** + * The label of the InputText. + * + * @remarks + * Maximum 45 characters + * + * @deprecated Use the `label` and `description` from the {@link LabelComponent} + */ + label?: string /** The minimum length of the text the user has to provide */ minLength?: number /** The maximum length of the text the user has to provide */ @@ -321,3 +348,25 @@ export interface ContainerComponent extends BaseComponent { /** Whether the container should be a spoiler (or blurred out). Defaults to `false` */ spoiler?: boolean } + +/** https://discord.com/developers/docs/components/reference#label-label-structure */ +export interface LabelComponent extends BaseComponent { + type: MessageComponentTypes.Label + + /** + * The label text + * + * @remarks + * Max 45 characters. + */ + label: string + /** + * An optional description text for the label + * + * @remarks + * Max 100 characters. + */ + description?: string + /** The component within the label */ + component: TextInputComponent | StringSelectComponent +} diff --git a/packages/types/src/shared.ts b/packages/types/src/shared.ts index 3ce38106e..c958fc769 100644 --- a/packages/types/src/shared.ts +++ b/packages/types/src/shared.ts @@ -24,3 +24,5 @@ export type PickPartial = { [P in keyof T]?: T[P] | undefi // Functions are objects for TS, so we need to check for them explicitly export type RecursivePartial = T extends object ? (T extends (...args: never[]) => unknown ? T : { [K in keyof T]?: RecursivePartial }) : T + +export type Require = Omit & { [P in K]-?: T[P] }