diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index cd651c745349..4904104e4179 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -1,4 +1,10 @@ -import { APIActionRowComponent, APIMessageComponent, ButtonStyle, ComponentType } from 'discord-api-types/v9'; +import { + APIActionRowComponent, + APIActionRowComponentTypes, + APIMessageActionRowComponent, + ButtonStyle, + ComponentType, +} from 'discord-api-types/v9'; import { ActionRow, ButtonComponent, createComponent, SelectMenuComponent, SelectMenuOption } from '../../src'; const rowWithButtonData: APIActionRowComponent = { @@ -43,7 +49,7 @@ describe('Action Row Components', () => { }); test('GIVEN valid JSON input THEN valid JSON output is given', () => { - const actionRowData: APIActionRowComponent = { + const actionRowData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -75,10 +81,43 @@ describe('Action Row Components', () => { expect(new ActionRow(actionRowData).toJSON()).toEqual(actionRowData); expect(new ActionRow().toJSON()).toEqual({ type: ComponentType.ActionRow, components: [] }); expect(() => createComponent({ type: ComponentType.ActionRow, components: [] })).not.toThrowError(); - // @ts-expect-error expect(() => createComponent({ type: 42, components: [] })).toThrowError(); }); test('GIVEN valid builder options THEN valid JSON output is given', () => { + const rowWithButtonData: APIActionRowComponent = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, + ], + }; + + const rowWithSelectMenuData: APIActionRowComponent = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.SelectMenu, + custom_id: '1234', + options: [ + { + label: 'one', + value: 'one', + }, + { + label: 'two', + value: 'two', + }, + ], + max_values: 10, + min_values: 12, + }, + ], + }; + const button = new ButtonComponent().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); const selectMenu = new SelectMenuComponent() .setCustomId('1234') diff --git a/packages/builders/__tests__/components/textInput.test.ts b/packages/builders/__tests__/components/textInput.test.ts new file mode 100644 index 000000000000..d88dc5285fed --- /dev/null +++ b/packages/builders/__tests__/components/textInput.test.ts @@ -0,0 +1,126 @@ +import { APITextInputComponent, ComponentType, TextInputStyle } from 'discord-api-types/v9'; +import { + labelValidator, + maxLengthValidator, + minLengthValidator, + placeholderValidator, + valueValidator, + textInputStyleValidator, +} from '../../src/components/textInput/Assertions'; +import { TextInputComponent } from '../../src/components/textInput/TextInput'; + +const superLongStr = 'a'.repeat(5000); + +const textInputComponent = () => new TextInputComponent(); + +describe('Text Input Components', () => { + describe('Assertion Tests', () => { + test('GIVEN valid label THEN validator does not throw', () => { + expect(() => labelValidator.parse('foobar')).not.toThrowError(); + }); + + test('GIVEN invalid label THEN validator does throw', () => { + expect(() => labelValidator.parse(24)).toThrowError(); + expect(() => labelValidator.parse(undefined)).toThrowError(); + }); + + test('GIVEN valid style THEN validator does not throw', () => { + expect(() => textInputStyleValidator.parse(TextInputStyle.Paragraph)).not.toThrowError(); + expect(() => textInputStyleValidator.parse(TextInputStyle.Short)).not.toThrowError(); + }); + + test('GIVEN invalid style THEN validator does throw', () => { + expect(() => textInputStyleValidator.parse(24)).toThrowError(); + }); + + test('GIVEN valid min length THEN validator does not throw', () => { + expect(() => minLengthValidator.parse(10)).not.toThrowError(); + }); + + test('GIVEN invalid min length THEN validator does throw', () => { + expect(() => minLengthValidator.parse(-1)).toThrowError(); + }); + + test('GIVEN valid max length THEN validator does not throw', () => { + expect(() => maxLengthValidator.parse(10)).not.toThrowError(); + }); + + test('GIVEN invalid min length THEN validator does throw', () => { + expect(() => maxLengthValidator.parse(4001)).toThrowError(); + }); + + test('GIVEN valid value THEN validator does not throw', () => { + expect(() => valueValidator.parse('foobar')).not.toThrowError(); + }); + + test('GIVEN invalid value THEN validator does throw', () => { + expect(() => valueValidator.parse(superLongStr)).toThrowError(); + }); + + test('GIVEN valid placeholder THEN validator does not throw', () => { + expect(() => placeholderValidator.parse('foobar')).not.toThrowError(); + }); + + test('GIVEN invalid value THEN validator does throw', () => { + expect(() => placeholderValidator.parse(superLongStr)).toThrowError(); + }); + + test('GIVEN valid fields THEN builder does not throw', () => { + expect(() => { + textInputComponent().setCustomId('foobar').setLabel('test').setStyle(TextInputStyle.Paragraph).toJSON(); + }).not.toThrowError(); + + expect(() => { + textInputComponent() + .setCustomId('foobar') + .setLabel('test') + .setMaxLength(100) + .setMinLength(1) + .setPlaceholder('bar') + .setRequired(true) + .setStyle(TextInputStyle.Paragraph) + .toJSON(); + }).not.toThrowError(); + }); + }); + + test('GIVEN invalid fields THEN builder throws', () => { + expect(() => textInputComponent().toJSON()).toThrowError(); + expect(() => { + textInputComponent() + .setCustomId('test') + .setMaxLength(100) + .setPlaceholder('hello') + .setStyle(TextInputStyle.Paragraph) + .toJSON(); + }).toThrowError(); + }); + + test('GIVEN valid input THEN valid JSON outputs are given', () => { + const textInputData: APITextInputComponent = { + type: ComponentType.TextInput, + label: 'label', + custom_id: 'custom id', + placeholder: 'placeholder', + max_length: 100, + min_length: 10, + value: 'value', + required: false, + style: TextInputStyle.Paragraph, + }; + + expect(new TextInputComponent(textInputData).toJSON()).toEqual(textInputData); + expect( + textInputComponent() + .setCustomId(textInputData.custom_id) + .setLabel(textInputData.label) + .setPlaceholder(textInputData.placeholder) + .setMaxLength(textInputData.max_length) + .setMinLength(textInputData.min_length) + .setValue(textInputData.value) + .setRequired(textInputData.required) + .setStyle(textInputData.style) + .toJSON(), + ).toEqual(textInputData); + }); +}); diff --git a/packages/builders/__tests__/interactions/modal.test.ts b/packages/builders/__tests__/interactions/modal.test.ts new file mode 100644 index 000000000000..3bb427670dd9 --- /dev/null +++ b/packages/builders/__tests__/interactions/modal.test.ts @@ -0,0 +1,88 @@ +import { APIModalInteractionResponseCallbackData, ComponentType, TextInputStyle } from 'discord-api-types/v9'; +import { ActionRow, ButtonComponent, Modal, ModalActionRowComponent, TextInputComponent } from '../../src'; +import { + componentsValidator, + titleValidator, + validateRequiredParameters, +} from '../../src/interactions/modals/Assertions'; + +const modal = () => new Modal(); + +describe('Modals', () => { + describe('Assertion Tests', () => { + test('GIVEN valid title THEN validator does not throw', () => { + expect(() => titleValidator.parse('foobar')).not.toThrowError(); + }); + + test('GIVEN invalid title THEN validator does throw', () => { + expect(() => titleValidator.parse(42)).toThrowError(); + }); + + test('GIVEN valid components THEN validator does not throw', () => { + expect(() => componentsValidator.parse([new ActionRow(), new ActionRow()])).not.toThrowError(); + }); + + test('GIVEN invalid components THEN validator does throw', () => { + expect(() => componentsValidator.parse([new ButtonComponent(), new TextInputComponent()])).toThrowError(); + }); + + test('GIVEN valid required parameters THEN validator does not throw', () => { + expect(() => validateRequiredParameters('123', 'title', [new ActionRow(), new ActionRow()])).not.toThrowError(); + }); + + test('GIVEN invalid required parameters THEN validator does throw', () => { + expect(() => + // @ts-expect-error + validateRequiredParameters('123', undefined, [new ActionRow(), new ButtonComponent()]), + ).toThrowError(); + }); + }); + + test('GIVEN valid fields THEN builder does not throw', () => { + expect(() => modal().setTitle('test').setCustomId('foobar').setComponents(new ActionRow())).not.toThrowError(); + }); + + test('GIVEN invalid fields THEN builder does throw', () => { + expect(() => + // @ts-expect-error + modal().setTitle('test').setCustomId('foobar').setComponents([new ActionRow()]).toJSON(), + ).toThrowError(); + expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError(); + // @ts-expect-error + expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError(); + }); + + test('GIVEN valid input THEN valid JSON outputs are given', () => { + const modalData: APIModalInteractionResponseCallbackData = { + title: 'title', + custom_id: 'custom id', + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.TextInput, + label: 'label', + style: TextInputStyle.Paragraph, + custom_id: 'custom id', + }, + ], + }, + ], + }; + + expect(new Modal(modalData).toJSON()).toEqual(modalData); + + expect( + modal() + .setTitle(modalData.title) + .setCustomId('custom id') + .setComponents( + new ActionRow().addComponents( + new TextInputComponent().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph), + ), + ) + .toJSON(), + ).toEqual(modalData); + }); +}); diff --git a/packages/builders/package.json b/packages/builders/package.json index 2bd79bf33374..c2cc2e5fa3d6 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -52,7 +52,7 @@ "homepage": "https://discord.js.org", "dependencies": { "@sindresorhus/is": "^4.4.0", - "discord-api-types": "^0.27.0", + "discord-api-types": "^0.27.3", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.0", "tslib": "^2.3.1", diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index 4508d0bc7787..77c359bff3a5 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -1,26 +1,42 @@ -import { type APIActionRowComponent, ComponentType, APIMessageComponent } from 'discord-api-types/v9'; -import type { ButtonComponent, SelectMenuComponent } from '..'; +import { + APIActionRowComponent, + APIMessageActionRowComponent, + APIModalActionRowComponent, + ComponentType, +} from 'discord-api-types/v9'; +import type { ButtonComponent, SelectMenuComponent, TextInputComponent } from '../index'; import { Component } from './Component'; import { createComponent } from './Components'; import isEqual from 'fast-deep-equal'; -export type MessageComponent = ActionRowComponent | ActionRow; +export type MessageComponent = MessageActionRowComponent | ActionRow; +export type ModalComponent = ModalActionRowComponent | ActionRow; -export type ActionRowComponent = ButtonComponent | SelectMenuComponent; +export type MessageActionRowComponent = ButtonComponent | SelectMenuComponent; +export type ModalActionRowComponent = TextInputComponent; -// TODO: Add valid form component types /** * Represents an action row component */ -export class ActionRow extends Component< - Omit> & { type: ComponentType.ActionRow }, 'components'> +export class ActionRow< + T extends ModalActionRowComponent | MessageActionRowComponent = ModalActionRowComponent | MessageActionRowComponent, +> extends Component< + Omit< + Partial> & { + type: ComponentType.ActionRow; + }, + 'components' + > > { /** * The components within this action row */ public readonly components: T[]; - public constructor({ components, ...data }: Partial> = {}) { + public constructor({ + components, + ...data + }: Partial> = {}) { super({ type: ComponentType.ActionRow, ...data }); this.components = (components?.map((c) => createComponent(c)) ?? []) as T[]; } @@ -44,14 +60,14 @@ export class ActionRow extend return this; } - public toJSON(): APIActionRowComponent { + public toJSON(): APIActionRowComponent> { return { ...this.data, - components: this.components.map((component) => component.toJSON()), + components: this.components.map((component) => component.toJSON()) as ReturnType[], }; } - public equals(other: APIActionRowComponent | ActionRow) { + public equals(other: APIActionRowComponent | ActionRow) { if (other instanceof ActionRow) { return isEqual(other.data, this.data) && isEqual(other.components, this.components); } diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index 15e866f8abde..a7ff010aed45 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,9 +1,13 @@ import type { JSONEncodable } from '../util/jsonEncodable'; import type { + APIActionRowComponent, APIActionRowComponentTypes, APIBaseComponent, + APIMessageActionRowComponent, + APIModalActionRowComponent, APIMessageComponent, ComponentType, + APIModalComponent, } from 'discord-api-types/v9'; import type { Equatable } from '../util/equatable'; @@ -14,16 +18,33 @@ export abstract class Component< DataType extends Partial> & { type: ComponentType; } = APIBaseComponent, -> implements JSONEncodable, Equatable +> implements + JSONEncodable< + | APIModalComponent + | APIMessageComponent + | APIActionRowComponent + >, + Equatable< + | Component + | APIActionRowComponentTypes + | APIActionRowComponent + > { /** * The API data associated with this component */ public readonly data: DataType; - public abstract toJSON(): APIMessageComponent; + public abstract toJSON(): + | APIActionRowComponentTypes + | APIActionRowComponent; - public abstract equals(other: Component | APIActionRowComponentTypes): boolean; + public abstract equals( + other: + | Component + | APIActionRowComponentTypes + | APIActionRowComponent, + ): boolean; public constructor(data: DataType) { this.data = data; diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index d9d60199dd15..57997731b305 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -1,11 +1,12 @@ -import { APIMessageComponent, ComponentType } from 'discord-api-types/v9'; -import { ActionRow, ButtonComponent, Component, SelectMenuComponent } from '../index'; -import type { MessageComponent } from './ActionRow'; +import { APIBaseComponent, APIMessageComponent, APIModalComponent, ComponentType } from 'discord-api-types/v9'; +import { ActionRow, ButtonComponent, Component, SelectMenuComponent, TextInputComponent } from '../index'; +import type { MessageComponent, ModalActionRowComponent } from './ActionRow'; export interface MappedComponentTypes { [ComponentType.ActionRow]: ActionRow; [ComponentType.Button]: ButtonComponent; [ComponentType.SelectMenu]: SelectMenuComponent; + [ComponentType.TextInput]: TextInputComponent; } /** @@ -13,10 +14,10 @@ export interface MappedComponentTypes { * @param data The api data to transform to a component class */ export function createComponent( - data: APIMessageComponent & { type: T }, + data: (APIMessageComponent | APIModalComponent) & { type: T }, ): MappedComponentTypes[T]; -export function createComponent(data: C): C; -export function createComponent(data: APIMessageComponent | MessageComponent): Component { +export function createComponent(data: C): C; +export function createComponent(data: APIModalComponent | APIMessageComponent | Component): Component { if (data instanceof Component) { return data; } @@ -28,8 +29,9 @@ export function createComponent(data: APIMessageComponent | MessageComponent): C return new ButtonComponent(data); case ComponentType.SelectMenu: return new SelectMenuComponent(data); + case ComponentType.TextInput: + return new TextInputComponent(data); default: - // @ts-expect-error - throw new Error(`Cannot serialize component type: ${data.type as number}`); + throw new Error(`Cannot serialize component type: ${(data as APIBaseComponent).type}`); } } diff --git a/packages/builders/src/components/textInput/Assertions.ts b/packages/builders/src/components/textInput/Assertions.ts new file mode 100644 index 000000000000..2b8ae2a2d397 --- /dev/null +++ b/packages/builders/src/components/textInput/Assertions.ts @@ -0,0 +1,17 @@ +import { TextInputStyle } from 'discord-api-types/v9'; +import { z } from 'zod'; +import { customIdValidator } from '../Assertions'; + +export const textInputStyleValidator = z.nativeEnum(TextInputStyle); +export const minLengthValidator = z.number().int().min(0).max(4000); +export const maxLengthValidator = z.number().int().min(1).max(4000); +export const requiredValidator = z.boolean(); +export const valueValidator = z.string().max(4000); +export const placeholderValidator = z.string().max(100); +export const labelValidator = z.string().min(1).max(45); + +export function validateRequiredParameters(customId?: string, style?: TextInputStyle, label?: string) { + customIdValidator.parse(customId); + textInputStyleValidator.parse(style); + labelValidator.parse(label); +} diff --git a/packages/builders/src/components/textInput/TextInput.ts b/packages/builders/src/components/textInput/TextInput.ts new file mode 100644 index 000000000000..15e260dc4085 --- /dev/null +++ b/packages/builders/src/components/textInput/TextInput.ts @@ -0,0 +1,37 @@ +import type { APITextInputComponent } from 'discord-api-types/v9'; +import { + maxLengthValidator, + minLengthValidator, + placeholderValidator, + requiredValidator, + valueValidator, + validateRequiredParameters, +} from './Assertions'; +import { UnsafeTextInputComponent } from './UnsafeTextInput'; + +export class TextInputComponent extends UnsafeTextInputComponent { + public override setMinLength(minLength: number) { + return super.setMinLength(minLengthValidator.parse(minLength)); + } + + public override setMaxLength(maxLength: number) { + return super.setMaxLength(maxLengthValidator.parse(maxLength)); + } + + public override setRequired(required = true) { + return super.setRequired(requiredValidator.parse(required)); + } + + public override setValue(value: string) { + return super.setValue(valueValidator.parse(value)); + } + + public override setPlaceholder(placeholder: string) { + return super.setPlaceholder(placeholderValidator.parse(placeholder)); + } + + public override toJSON(): APITextInputComponent { + validateRequiredParameters(this.data.custom_id, this.data.style, this.data.label); + return super.toJSON(); + } +} diff --git a/packages/builders/src/components/textInput/UnsafeTextInput.ts b/packages/builders/src/components/textInput/UnsafeTextInput.ts new file mode 100644 index 000000000000..52462a7e8741 --- /dev/null +++ b/packages/builders/src/components/textInput/UnsafeTextInput.ts @@ -0,0 +1,154 @@ +import { ComponentType, type TextInputStyle, type APITextInputComponent } from 'discord-api-types/v9'; +import { Component } from '../../index'; +import isEqual from 'fast-deep-equal'; + +export class UnsafeTextInputComponent extends Component< + Partial & { type: ComponentType.TextInput } +> { + public constructor(data?: APITextInputComponent & { type?: ComponentType.TextInput }) { + super({ type: ComponentType.TextInput, ...data }); + } + + /** + * The style of this text input + */ + public get style() { + return this.data.style; + } + + /** + * The custom id of this text input + */ + public get customId() { + return this.data.custom_id; + } + + /** + * The label for this text input + */ + public get label() { + return this.data.label; + } + + /** + * The placeholder text for this text input + */ + public get placeholder() { + return this.data.placeholder; + } + + /** + * The default value for this text input + */ + public get value() { + return this.data.value; + } + + /** + * The minimum length of this text input + */ + public get minLength() { + return this.data.min_length; + } + + /** + * The maximum length of this text input + */ + public get maxLength() { + return this.data.max_length; + } + + /** + * Whether this text input is required + */ + public get required() { + return this.data.required; + } + + /** + * Sets the custom id for this text input + * @param customId The custom id of this text input + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Sets the label for this text input + * @param label The label for this text input + */ + public setLabel(label: string) { + this.data.label = label; + return this; + } + + /** + * Sets the style for this text input + * @param style The style for this text input + */ + public setStyle(style: TextInputStyle) { + this.data.style = style; + return this; + } + + /** + * Sets the minimum length of text for this text input + * @param minLength The minimum length of text for this text input + */ + public setMinLength(minLength: number) { + this.data.min_length = minLength; + return this; + } + + /** + * Sets the maximum length of text for this text input + * @param maxLength The maximum length of text for this text input + */ + public setMaxLength(maxLength: number) { + this.data.max_length = maxLength; + return this; + } + + /** + * Sets the placeholder of this text input + * @param placeholder The placeholder of this text input + */ + public setPlaceholder(placeholder: string) { + this.data.placeholder = placeholder; + return this; + } + + /** + * Sets the value of this text input + * @param value The value for this text input + */ + public setValue(value: string) { + this.data.value = value; + return this; + } + + /** + * Sets whether this text input is required or not + * @param required Whether this text input is required or not + */ + public setRequired(required = true) { + this.data.required = required; + return this; + } + + public toJSON(): APITextInputComponent { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + ...this.data, + } as APITextInputComponent; + } + + public equals(other: UnsafeTextInputComponent | APITextInputComponent): boolean { + if (other instanceof UnsafeTextInputComponent) { + return isEqual(other.data, this.data); + } + + return isEqual(other, this.data); + } +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 15e7d25f3a92..aeba12bf3735 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -8,6 +8,12 @@ export * from './components/ActionRow'; export * from './components/button/Button'; export * from './components/Component'; export * from './components/Components'; +export * from './components/textInput/TextInput'; +export * as TextInputAssertions from './components/textInput/Assertions'; +export * from './components/textInput/UnsafeTextInput'; +export * from './interactions/modals/UnsafeModal'; +export * from './interactions/modals/Modal'; +export * as ModalAssertions from './interactions/modals/Assertions'; export * from './components/selectMenu/SelectMenu'; export * from './components/selectMenu/SelectMenuOption'; export * from './components/button/UnsafeButton'; diff --git a/packages/builders/src/interactions/modals/Assertions.ts b/packages/builders/src/interactions/modals/Assertions.ts new file mode 100644 index 000000000000..cb4e6ee33f2e --- /dev/null +++ b/packages/builders/src/interactions/modals/Assertions.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { ActionRow, type ModalActionRowComponent } from '../..'; +import { customIdValidator } from '../../components/Assertions'; + +export const titleValidator = z.string().min(1).max(45); +export const componentsValidator = z.array(z.instanceof(ActionRow)).min(1); + +export function validateRequiredParameters( + customId?: string, + title?: string, + components?: ActionRow[], +) { + customIdValidator.parse(customId); + titleValidator.parse(title); + componentsValidator.parse(components); +} diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts new file mode 100644 index 000000000000..d61f26435ecd --- /dev/null +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -0,0 +1,19 @@ +import type { APIModalInteractionResponseCallbackData } from 'discord-api-types/v9'; +import { customIdValidator } from '../../components/Assertions'; +import { titleValidator, validateRequiredParameters } from './Assertions'; +import { UnsafeModal } from './UnsafeModal'; + +export class Modal extends UnsafeModal { + public override setCustomId(customId: string): this { + return super.setCustomId(customIdValidator.parse(customId)); + } + + public override setTitle(title: string) { + return super.setTitle(titleValidator.parse(title)); + } + + public override toJSON(): APIModalInteractionResponseCallbackData { + validateRequiredParameters(this.data.custom_id, this.data.title, this.components); + return super.toJSON(); + } +} diff --git a/packages/builders/src/interactions/modals/UnsafeModal.ts b/packages/builders/src/interactions/modals/UnsafeModal.ts new file mode 100644 index 000000000000..915ad55d0e86 --- /dev/null +++ b/packages/builders/src/interactions/modals/UnsafeModal.ts @@ -0,0 +1,80 @@ +import type { + APIActionRowComponent, + APIModalActionRowComponent, + APIModalInteractionResponseCallbackData, +} from 'discord-api-types/v9'; +import { ActionRow, createComponent, JSONEncodable, ModalActionRowComponent } from '../../index'; + +export class UnsafeModal implements JSONEncodable { + protected readonly data: Partial>; + public readonly components: ActionRow[] = []; + + public constructor({ components, ...data }: Partial = {}) { + this.data = { ...data }; + this.components = (components?.map((c) => createComponent(c)) ?? []) as ActionRow[]; + } + + /** + * The custom id of this modal + */ + public get customId() { + return this.data.custom_id; + } + + /** + * The title of this modal + */ + public get title() { + return this.data.title; + } + + /** + * Sets the title of the modal + * @param title The title of the modal + */ + public setTitle(title: string) { + this.data.title = title; + return this; + } + + /** + * Sets the custom id of the modal + * @param customId The custom id of this modal + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Adds components to this modal + * @param components The components to add to this modal + */ + public addComponents( + ...components: (ActionRow | APIActionRowComponent)[] + ) { + this.components.push( + ...components.map((component) => + component instanceof ActionRow ? component : new ActionRow(component), + ), + ); + return this; + } + + /** + * Sets the components in this modal + * @param components The components to set this modal to + */ + public setComponents(...components: ActionRow[]) { + this.components.splice(0, this.components.length, ...components); + return this; + } + + public toJSON(): APIModalInteractionResponseCallbackData { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + ...this.data, + components: this.components.map((component) => component.toJSON()), + } as APIModalInteractionResponseCallbackData; + } +} diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index 4854e70bc8d6..94fb37530166 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -52,7 +52,7 @@ "@discordjs/rest": "workspace:^", "@sapphire/snowflake": "^3.1.0", "@types/ws": "^8.2.2", - "discord-api-types": "^0.27.0", + "discord-api-types": "^0.27.3", "lodash.snakecase": "^4.1.1", "undici": "^4.14.1", "ws": "^8.5.0" diff --git a/packages/discord.js/src/client/actions/InteractionCreate.js b/packages/discord.js/src/client/actions/InteractionCreate.js index 8c36ec917de9..1e2a6591f7b1 100644 --- a/packages/discord.js/src/client/actions/InteractionCreate.js +++ b/packages/discord.js/src/client/actions/InteractionCreate.js @@ -6,6 +6,7 @@ const AutocompleteInteraction = require('../../structures/AutocompleteInteractio const ButtonInteraction = require('../../structures/ButtonInteraction'); const ChatInputCommandInteraction = require('../../structures/ChatInputCommandInteraction'); const MessageContextMenuCommandInteraction = require('../../structures/MessageContextMenuCommandInteraction'); +const ModalSubmitInteraction = require('../../structures/ModalSubmitInteraction'); const SelectMenuInteraction = require('../../structures/SelectMenuInteraction'); const UserContextMenuCommandInteraction = require('../../structures/UserContextMenuCommandInteraction'); const Events = require('../../util/Events'); @@ -57,6 +58,9 @@ class InteractionCreateAction extends Action { case InteractionType.ApplicationCommandAutocomplete: InteractionClass = AutocompleteInteraction; break; + case InteractionType.ModalSubmit: + InteractionClass = ModalSubmitInteraction; + break; default: client.emit(Events.Debug, `[INTERACTION] Received interaction with unknown type: ${data.type}`); return; diff --git a/packages/discord.js/src/errors/Messages.js b/packages/discord.js/src/errors/Messages.js index 0553f2ec47c1..0660d3cccc87 100644 --- a/packages/discord.js/src/errors/Messages.js +++ b/packages/discord.js/src/errors/Messages.js @@ -141,6 +141,10 @@ const Messages = { COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND_GROUP: 'No subcommand group specified for interaction.', AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION: 'No focused option for autocomplete interaction.', + MODAL_SUBMIT_INTERACTION_FIELD_NOT_FOUND: customId => `Required field with custom id "${customId}" not found.`, + MODAL_SUBMIT_INTERACTION_FIELD_TYPE: (customId, type, expected) => + `Field with custom id "${customId}" is of type: ${type}; expected ${expected}.`, + INVITE_MISSING_SCOPES: 'At least one valid scope must be provided for the invite', NOT_IMPLEMENTED: (what, name) => `Method ${what} not implemented on ${name}.`, diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index 927b13dc2673..7945d6b625ef 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -124,6 +124,9 @@ exports.MessageContextMenuCommandInteraction = require('./structures/MessageCont exports.MessageMentions = require('./structures/MessageMentions'); exports.MessagePayload = require('./structures/MessagePayload'); exports.MessageReaction = require('./structures/MessageReaction'); +exports.Modal = require('./structures/Modal'); +exports.ModalSubmitInteraction = require('./structures/ModalSubmitInteraction'); +exports.ModalSubmitFieldsResolver = require('./structures/ModalSubmitFieldsResolver'); exports.NewsChannel = require('./structures/NewsChannel'); exports.OAuth2Guild = require('./structures/OAuth2Guild'); exports.PartialGroupDMChannel = require('./structures/PartialGroupDMChannel'); @@ -143,6 +146,7 @@ exports.StoreChannel = require('./structures/StoreChannel'); exports.Team = require('./structures/Team'); exports.TeamMember = require('./structures/TeamMember'); exports.TextChannel = require('./structures/TextChannel'); +exports.TextInputComponent = require('./structures/TextInputComponent'); exports.ThreadChannel = require('./structures/ThreadChannel'); exports.ThreadMember = require('./structures/ThreadMember'); exports.Typing = require('./structures/Typing'); @@ -193,6 +197,7 @@ exports.RESTJSONErrorCodes = require('discord-api-types/v9').RESTJSONErrorCodes; exports.StageInstancePrivacyLevel = require('discord-api-types/v9').StageInstancePrivacyLevel; exports.StickerType = require('discord-api-types/v9').StickerType; exports.StickerFormatType = require('discord-api-types/v9').StickerFormatType; +exports.TextInputStyle = require('discord-api-types/v9').TextInputStyle; exports.UserFlags = require('discord-api-types/v9').UserFlags; exports.WebhookType = require('discord-api-types/v9').WebhookType; exports.UnsafeButtonComponent = require('@discordjs/builders').UnsafeButtonComponent; diff --git a/packages/discord.js/src/structures/CommandInteraction.js b/packages/discord.js/src/structures/CommandInteraction.js index 4987d09b2a78..69fa4558ef0d 100644 --- a/packages/discord.js/src/structures/CommandInteraction.js +++ b/packages/discord.js/src/structures/CommandInteraction.js @@ -203,6 +203,7 @@ class CommandInteraction extends Interaction { editReply() {} deleteReply() {} followUp() {} + showModal() {} } InteractionResponses.applyToClass(CommandInteraction, ['deferUpdate', 'update']); diff --git a/packages/discord.js/src/structures/Interaction.js b/packages/discord.js/src/structures/Interaction.js index a341708eaa31..1467679de57b 100644 --- a/packages/discord.js/src/structures/Interaction.js +++ b/packages/discord.js/src/structures/Interaction.js @@ -191,6 +191,14 @@ class Interaction extends Base { return this.isContextMenuCommand() && this.commandType === ApplicationCommandType.Message; } + /** + * Indicates whether this interaction is a {@link ModalSubmitInteraction} + * @returns {boolean} + */ + isModalSubmit() { + return this.type === InteractionType.ModalSubmit; + } + /** * Indicates whether this interaction is an {@link AutocompleteInteraction} * @returns {boolean} diff --git a/packages/discord.js/src/structures/MessageComponentInteraction.js b/packages/discord.js/src/structures/MessageComponentInteraction.js index 1e393b3ff449..e9d9e4b21a30 100644 --- a/packages/discord.js/src/structures/MessageComponentInteraction.js +++ b/packages/discord.js/src/structures/MessageComponentInteraction.js @@ -90,6 +90,7 @@ class MessageComponentInteraction extends Interaction { followUp() {} deferUpdate() {} update() {} + showModal() {} } InteractionResponses.applyToClass(MessageComponentInteraction); diff --git a/packages/discord.js/src/structures/Modal.js b/packages/discord.js/src/structures/Modal.js new file mode 100644 index 000000000000..b9ed1c399e32 --- /dev/null +++ b/packages/discord.js/src/structures/Modal.js @@ -0,0 +1,12 @@ +'use strict'; + +const { Modal: BuildersModal } = require('@discordjs/builders'); +const Transformers = require('../util/Transformers'); + +class Modal extends BuildersModal { + constructor(data) { + super(Transformers.toSnakeCase(data)); + } +} + +module.exports = Modal; diff --git a/packages/discord.js/src/structures/ModalSubmitFieldsResolver.js b/packages/discord.js/src/structures/ModalSubmitFieldsResolver.js new file mode 100644 index 000000000000..8db89acd8bf8 --- /dev/null +++ b/packages/discord.js/src/structures/ModalSubmitFieldsResolver.js @@ -0,0 +1,54 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { ComponentType } = require('discord-api-types/v9'); +const { TypeError } = require('../errors'); + +/** + * Represents the serialized fields from a modal submit interaction + */ +class ModalSubmitFieldsResolver { + constructor(components) { + /** + * The components within the modal + * @type {Array>} The components in the modal + */ + this.components = components; + + /** + * The extracted fields from the modal + * @type {Collection} The fields in the modal + */ + this.fields = components.reduce((accumulator, next) => { + next.components.forEach(c => accumulator.set(c.customId, c)); + return accumulator; + }, new Collection()); + } + + /** + * Gets a field given a custom id from a component + * @param {string} customId The custom id of the component + * @returns {ModalFieldData} + */ + getField(customId) { + const field = this.fields.get(customId); + if (!field) throw new TypeError('MODAL_SUBMIT_INTERACTION_FIELD_NOT_FOUND', customId); + return field; + } + + /** + * Gets the value of a text input component given a custom id + * @param {string} customId The custom id of the text input component + * @returns {string} + */ + getTextInputValue(customId) { + const field = this.getField(customId); + const expectedType = ComponentType.TextInput; + if (field.type !== expectedType) { + throw new TypeError('MODAL_SUBMIT_INTERACTION_FIELD_TYPE', customId, field.type, expectedType); + } + return field.value; + } +} + +module.exports = ModalSubmitFieldsResolver; diff --git a/packages/discord.js/src/structures/ModalSubmitInteraction.js b/packages/discord.js/src/structures/ModalSubmitInteraction.js new file mode 100644 index 000000000000..4a023a97814f --- /dev/null +++ b/packages/discord.js/src/structures/ModalSubmitInteraction.js @@ -0,0 +1,93 @@ +'use strict'; + +const { createComponent } = require('@discordjs/builders'); +const Interaction = require('./Interaction'); +const InteractionWebhook = require('./InteractionWebhook'); +const ModalSubmitFieldsResolver = require('./ModalSubmitFieldsResolver'); +const InteractionResponses = require('./interfaces/InteractionResponses'); + +/** + * @typedef {Object} ModalFieldData + * @property {string} value The value of the field + * @property {ComponentType} type The component type of the field + * @property {string} customId The custom id of the field + */ + +/** + * Represents a modal interaction + * @implements {InteractionResponses} + */ +class ModalSubmitInteraction extends Interaction { + constructor(client, data) { + super(client, data); + /** + * The custom id of the modal. + * @type {string} + */ + this.customId = data.data.custom_id; + + if ('message' in data) { + /** + * The message associated with this interaction + * @type {?(Message|APIMessage)} + */ + this.message = this.channel?.messages._add(data.message) ?? data.message; + } else { + this.message = null; + } + + /** + * The components within the modal + * @type {ActionRow[]} + */ + this.components = data.data.components?.map(c => createComponent(c)) ?? []; + + /** + * The fields within the modal + * @type {ModalSubmitFieldsResolver} + */ + this.fields = new ModalSubmitFieldsResolver(this.components); + + /** + * An associated interaction webhook, can be used to further interact with this interaction + * @type {InteractionWebhook} + */ + this.webhook = new InteractionWebhook(this.client, this.applicationId, this.token); + } + + /** + * Transforms component data to discord.js-compatible data + * @param {*} rawComponent The data to transform + * @returns {ModalFieldData[]} + */ + static transformComponent(rawComponent) { + return { + value: rawComponent.value, + type: rawComponent.type, + customId: rawComponent.custom_id, + }; + } + + /** + * Whether this is from a {@link MessageComponentInteraction}. + * @returns {boolean} + */ + isFromMessage() { + return Boolean(this.message); + } + + // These are here only for documentation purposes - they are implemented by InteractionResponses + /* eslint-disable no-empty-function */ + deferReply() {} + reply() {} + fetchReply() {} + editReply() {} + deleteReply() {} + followUp() {} + deferUpdate() {} + update() {} +} + +InteractionResponses.applyToClass(ModalSubmitInteraction, 'showModal'); + +module.exports = ModalSubmitInteraction; diff --git a/packages/discord.js/src/structures/TextInputComponent.js b/packages/discord.js/src/structures/TextInputComponent.js new file mode 100644 index 000000000000..28a214f2be16 --- /dev/null +++ b/packages/discord.js/src/structures/TextInputComponent.js @@ -0,0 +1,12 @@ +'use strict'; + +const { TextInputComponent: BuildersTextInputComponent } = require('@discordjs/builders'); +const Transformers = require('../util/Transformers'); + +class TextInputComponent extends BuildersTextInputComponent { + constructor(data) { + super(Transformers.toSnakeCase(data)); + } +} + +module.exports = TextInputComponent; diff --git a/packages/discord.js/src/structures/interfaces/InteractionResponses.js b/packages/discord.js/src/structures/interfaces/InteractionResponses.js index 35d7ba701204..30d67b1aa507 100644 --- a/packages/discord.js/src/structures/interfaces/InteractionResponses.js +++ b/packages/discord.js/src/structures/interfaces/InteractionResponses.js @@ -1,9 +1,18 @@ 'use strict'; +const { isJSONEncodable } = require('@discordjs/builders'); const { InteractionResponseType, MessageFlags, Routes } = require('discord-api-types/v9'); const { Error } = require('../../errors'); +const Transformers = require('../../util/Transformers'); const MessagePayload = require('../MessagePayload'); +/** + * @typedef {Object} ModalData + * @property {string} title The title of the modal + * @property {string} customId The custom id of the modal + * @property {ActionRowData[]} components The components within this modal + */ + /** * Interface for classes that support shared interaction response types. * @interface @@ -225,6 +234,21 @@ class InteractionResponses { return options.fetchReply ? this.fetchReply() : undefined; } + /** + * Shows a modal component + * @param {APIModal|ModalData|Modal} modal The modal to show + */ + async showModal(modal) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + body: { + type: InteractionResponseType.Modal, + data: isJSONEncodable(modal) ? modal.toJSON() : Transformers.toSnakeCase(modal), + }, + }); + this.replied = true; + } + static applyToClass(structure, ignore = []) { const props = [ 'deferReply', @@ -235,6 +259,7 @@ class InteractionResponses { 'followUp', 'deferUpdate', 'update', + 'showModal', ]; for (const prop of props) { diff --git a/packages/discord.js/src/util/Components.js b/packages/discord.js/src/util/Components.js index 59a816be3084..3b91720b0dbf 100644 --- a/packages/discord.js/src/util/Components.js +++ b/packages/discord.js/src/util/Components.js @@ -1,44 +1,46 @@ 'use strict'; +// This file contains the typedefs for camel-cased json data + /** * @typedef {Object} BaseComponentData - * @property {ComponentType} type + * @property {ComponentType} type The type of component */ - /** * @typedef {BaseComponentData} ActionRowData - * @property {ComponentData[]} components + * @property {ComponentData[]} components The components in this action row */ - /** * @typedef {BaseComponentData} ButtonComponentData - * @property {ButtonStyle} style - * @property {?boolean} disabled - * @property {string} label - * @property {?APIComponentEmoji} emoji - * @property {?string} customId - * @property {?string} url + * @property {ButtonStyle} style The style of the button + * @property {?boolean} disabled Whether this button is disabled + * @property {string} label The label of this button + * @property {?APIComponentEmoji} emoji The emoji on this button + * @property {?string} customId The custom id of the button + * @property {?string} url The URL of the button */ - /** * @typedef {object} SelectMenuComponentOptionData - * @property {string} label - * @property {string} value - * @property {?string} description - * @property {?APIComponentEmoji} emoji - * @property {?boolean} default + * @property {string} label The label of the option + * @property {string} value The value of the option + * @property {?string} description The description of the option + * @property {?APIComponentEmoji} emoji The emoji on the option + * @property {?boolean} default Whether this option is selected by default */ - /** * @typedef {BaseComponentData} SelectMenuComponentData - * @property {string} customId - * @property {?boolean} disabled - * @property {?number} maxValues - * @property {?number} minValues - * @property {?SelectMenuComponentOptionData[]} options - * @property {?string} placeholder + * @property {string} customId The custom id of the select menu + * @property {?boolean} disabled Whether the select menu is disabled or not + * @property {?number} maxValues The maximum amount of options that can be selected + * @property {?number} minValues The minimum amount of options that can be selected + * @property {?SelectMenuComponentOptionData[]} options The options in this select menu + * @property {?string} placeholder The placeholder of the select menu */ /** - * @typedef {ActionRowData|ButtonComponentData|SelectMenuComponentData} ComponentData + * @typedef {ActionRowData|ButtonComponentData|SelectMenuComponentData} MessageComponentData + / + +/** + * @typedef {ActionRowData|ButtonComponentData|SelectMenuComponentData|TextInputComponentData} ComponentData */ diff --git a/packages/discord.js/src/util/Embeds.js b/packages/discord.js/src/util/Embeds.js index d6294b2feb67..be791fc27480 100644 --- a/packages/discord.js/src/util/Embeds.js +++ b/packages/discord.js/src/util/Embeds.js @@ -2,47 +2,47 @@ /** * @typedef {Object} EmbedData - * @property {?string} title - * @property {?EmbedType} type - * @property {?string} description - * @property {?string} url - * @property {?string} timestamp - * @property {?number} color - * @property {?EmbedFooterData} footer - * @property {?EmbedImageData} image - * @property {?EmbedImageData} thumbnail - * @property {?EmbedProviderData} provider - * @property {?EmbedAuthorData} author - * @property {?EmbedFieldData[]} fields + * @property {?string} title The title of the embed + * @property {?EmbedType} type The type of the embed + * @property {?string} description The description of the embed + * @property {?string} url The URL of the embed + * @property {?string} timestamp The timestamp on the embed + * @property {?number} color The color of the embed + * @property {?EmbedFooterData} footer The footer of the embed + * @property {?EmbedImageData} image The image of the embed + * @property {?EmbedImageData} thumbnail The thumbnail of the embed + * @property {?EmbedProviderData} provider The provider of the embed + * @property {?EmbedAuthorData} author The author in the embed + * @property {?EmbedFieldData[]} fields The fields in this embed */ /** * @typedef {Object} EmbedFooterData - * @property {string} text - * @property {?string} iconURL + * @property {string} text The text of the footer + * @property {?string} iconURL The URL of the icon */ /** * @typedef {Object} EmbedImageData - * @property {?string} url + * @property {?string} url The URL of the image */ /** * @typedef {Object} EmbedProviderData - * @property {?string} name - * @property {?string} url + * @property {?string} name The name of the provider + * @property {?string} url The URL of the provider */ /** * @typedef {Object} EmbedAuthorData - * @property {string} name - * @property {?string} url - * @property {?string} iconURL + * @property {string} name The name of the author + * @property {?string} url The URL of the author + * @property {?string} iconURL The icon URL of the author */ /** * @typedef {Object} EmbedFieldData - * @property {string} name - * @property {string} value - * @property {?boolean} inline + * @property {string} name The name of the field + * @property {string} value The value of the field + * @property {?boolean} inline Whether to inline this field */ diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 5ba4c3708943..3e003d4ece30 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -1,6 +1,6 @@ import { ActionRow as BuilderActionRow, - ActionRowComponent, + MessageActionRowComponent, blockQuote, bold, ButtonComponent as BuilderButtonComponent, @@ -14,9 +14,11 @@ import { inlineCode, italic, memberNicknameMention, + Modal as BuilderModal, quote, roleMention, SelectMenuComponent as BuilderSelectMenuComponent, + TextInputComponent as BuilderTextInputComponent, spoiler, strikethrough, time, @@ -24,6 +26,7 @@ import { TimestampStylesString, underscore, userMention, + ModalActionRowComponent, } from '@discordjs/builders'; import { Collection } from '@discordjs/collection'; import { BaseImageURLOptions, ImageURLOptions, RawFile, REST, RESTOptions } from '@discordjs/rest'; @@ -95,6 +98,13 @@ import { APIMessageComponentEmoji, EmbedType, APIActionRowComponentTypes, + APIModalInteractionResponseCallbackData, + APIModalSubmitInteraction, + APIMessageActionRowComponent, + TextInputStyle, + APITextInputComponent, + APIModalActionRowComponent, + APIModalComponent, } from 'discord-api-types/v9'; import { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; @@ -198,17 +208,23 @@ export interface BaseComponentData { type?: ComponentType; } -export type ActionRowComponentData = ButtonComponentData | SelectMenuComponentData; +export type MessageActionRowComponentData = ButtonComponentData | SelectMenuComponentData; +export type ModalActionRowComponentData = TextInputComponentData; -export interface ActionRowData extends BaseComponentData { - components: ActionRowComponentData[]; +export interface ActionRowData + extends BaseComponentData { + components: T[]; } -export class ActionRow extends BuilderActionRow { +export class ActionRow< + T extends MessageActionRowComponent | ModalActionRowComponent = MessageActionRowComponent, +> extends BuilderActionRow { constructor( data?: - | ActionRowData - | (Omit, 'type'> & { type?: ComponentType.ActionRow }), + | ActionRowData + | (Omit, 'type'> & { + type?: ComponentType.ActionRow; + }), ); } @@ -336,6 +352,7 @@ export interface InteractionResponseFields deferReply(options?: InteractionDeferReplyOptions): Promise; fetchReply(): Promise>; followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + showModal(modal: Modal): Promise; } export abstract class CommandInteraction extends Interaction { @@ -374,6 +391,7 @@ export abstract class CommandInteraction e public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; + public showModal(modal: Modal): Promise; private transformOption( option: APIApplicationCommandOption, resolved: APIApplicationCommandInteractionData['resolved'], @@ -490,6 +508,14 @@ export class SelectMenuComponent extends BuilderSelectMenuComponent { ); } +export class TextInputComponent extends BuilderTextInputComponent { + public constructor(data?: TextInputComponentData | APITextInputComponent); +} + +export class Modal extends BuilderModal { + public constructor(data?: ModalData | APIModalActionRowComponent); +} + export interface EmbedData { title?: string; type?: EmbedType; @@ -1355,6 +1381,7 @@ export class Interaction extends Base { public isMessageComponent(): this is MessageComponentInteraction; public isSelectMenu(): this is SelectMenuInteraction; public isRepliable(): this is this & InteractionResponseFields; + public isModalSubmit(): this is ModalSubmitInteraction; } export class InteractionCollector extends Collector { @@ -1447,17 +1474,19 @@ export class LimitedCollection extends Collection { public keepOverLimit: ((value: V, key: K, collection: this) => boolean) | null; } -export type MessageCollectorOptionsParams = +export type MessageComponentType = Exclude; + +export type MessageCollectorOptionsParams = | { componentType?: T; } & MessageComponentCollectorOptions[T]>; -export type MessageChannelCollectorOptionsParams = +export type MessageChannelCollectorOptionsParams = | { componentType?: T; } & MessageChannelComponentCollectorOptions[T]>; -export type AwaitMessageCollectorOptionsParams = +export type AwaitMessageCollectorOptionsParams = | { componentType?: T } & Pick< InteractionCollectorOptions[T]>, keyof AwaitMessageComponentOptions @@ -1490,7 +1519,7 @@ export class Message extends Base { public get channel(): If; public channelId: Snowflake; public get cleanContent(): string; - public components: ActionRow[]; + public components: ActionRow[]; public content: string; public get createdAt(): Date; public createdTimestamp: number; @@ -1522,12 +1551,12 @@ export class Message extends Base { public webhookId: Snowflake | null; public flags: Readonly; public reference: MessageReference | null; - public awaitMessageComponent( + public awaitMessageComponent( options?: AwaitMessageCollectorOptionsParams, ): Promise[T]>; public awaitReactions(options?: AwaitReactionsOptions): Promise>; public createReactionCollector(options?: ReactionCollectorOptions): ReactionCollector; - public createMessageComponentCollector( + public createMessageComponentCollector( options?: MessageCollectorOptionsParams, ): InteractionCollector[T]>; public delete(): Promise; @@ -1541,7 +1570,7 @@ export class Message extends Base { public react(emoji: EmojiIdentifierResolvable): Promise; public removeAttachments(): Promise; public reply(options: string | MessagePayload | ReplyMessageOptions): Promise; - public resolveComponent(customId: string): ActionRowComponent | null; + public resolveComponent(customId: string): MessageActionRowComponent | null; public startThread(options: StartThreadOptions): Promise; public suppressEmbeds(suppress?: boolean): Promise; public toJSON(): unknown; @@ -1590,10 +1619,10 @@ export class MessageComponentInteraction e protected constructor(client: Client, data: RawMessageComponentInteractionData); public get component(): CacheTypeReducer< Cached, - ActionRowComponent, - Exclude>, - ActionRowComponent | Exclude>, - ActionRowComponent | Exclude> + MessageActionRowComponent, + Exclude>, + MessageActionRowComponent | Exclude>, + MessageActionRowComponent | Exclude> >; public componentType: Exclude; public customId: string; @@ -1618,6 +1647,7 @@ export class MessageComponentInteraction e public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; public update(options: InteractionUpdateOptions & { fetchReply: true }): Promise>; public update(options: string | MessagePayload | InteractionUpdateOptions): Promise; + public showModal(modal: Modal): Promise; } export class MessageContextMenuCommandInteraction< @@ -1706,6 +1736,57 @@ export class MessageReaction { public toJSON(): unknown; } +export interface ModalFieldData { + value: string; + type: ComponentType; + customId: string; +} + +export class ModalSubmitFieldsResolver { + constructor(components: ModalFieldData[][]); + public fields: Collection; + public getField(customId: string): ModalFieldData; + public getTextInputValue(customId: string): string; +} + +export interface ModalMessageModalSubmitInteraction + extends ModalSubmitInteraction { + message: GuildCacheMessage | null; + update(options: InteractionUpdateOptions & { fetchReply: true }): Promise>; + update(options: string | MessagePayload | InteractionUpdateOptions): Promise; + deferUpdate(options: InteractionDeferUpdateOptions & { fetchReply: true }): Promise>; + deferUpdate(options?: InteractionDeferUpdateOptions): Promise; + inGuild(): this is ModalMessageModalSubmitInteraction<'raw' | 'cached'>; + inCachedGuild(): this is ModalMessageModalSubmitInteraction<'cached'>; + inRawGuild(): this is ModalMessageModalSubmitInteraction<'raw'>; +} + +export interface ModalSubmitActionRow { + type: ComponentType.ActionRow; + components: ModalFieldData[]; +} + +export class ModalSubmitInteraction extends Interaction { + private constructor(client: Client, data: APIModalSubmitInteraction); + public readonly customId: string; + // TODO: fix this type when #7517 is implemented + public readonly components: ModalSubmitActionRow[]; + public readonly fields: ModalSubmitFieldsResolver; + public readonly webhook: InteractionWebhook; + public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; + public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; + public deleteReply(): Promise; + public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise>; + public deferReply(options: InteractionDeferReplyOptions & { fetchReply: true }): Promise>; + public deferReply(options?: InteractionDeferReplyOptions): Promise; + public fetchReply(): Promise>; + public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>; + public inCachedGuild(): this is ModalSubmitInteraction<'cached'>; + public inRawGuild(): this is ModalSubmitInteraction<'raw'>; + public isFromMessage(): this is ModalMessageModalSubmitInteraction; +} + export class NewsChannel extends BaseGuildTextChannel { public threads: ThreadManager; public type: ChannelType.GuildNews; @@ -2381,7 +2462,10 @@ export class Formatters extends null { public static userMention: typeof userMention; } -export type ComponentData = ActionRowComponentData | ButtonComponentData | SelectMenuComponentData; +export type ComponentData = + | MessageActionRowComponentData + | ModalActionRowComponentData + | ActionRowData; export class VoiceChannel extends BaseGuildVoiceChannel { public get speakable(): boolean; @@ -3132,8 +3216,8 @@ export interface TextBasedChannelFields extends PartialTextBasedChannelFields { lastMessageId: Snowflake | null; get lastMessage(): Message | null; lastPinTimestamp: number | null; - get lastPinAt(): Date | null; - awaitMessageComponent( + readonly lastPinAt: Date | null; + awaitMessageComponent( options?: AwaitMessageCollectorOptionsParams, ): Promise; awaitMessages(options?: AwaitMessagesOptions): Promise>; @@ -3141,7 +3225,7 @@ export interface TextBasedChannelFields extends PartialTextBasedChannelFields { messages: Collection | readonly MessageResolvable[] | number, filterOld?: boolean, ): Promise>; - createMessageComponentCollector( + createMessageComponentCollector( options?: MessageChannelCollectorOptionsParams, ): InteractionCollector; createMessageCollector(options?: MessageCollectorOptions): MessageCollector; @@ -4518,7 +4602,7 @@ export type ActionRowComponentOptions = | (Required & ButtonComponentData) | (Required & SelectMenuComponentData); -export type MessageActionRowComponentResolvable = ActionRowComponent | ActionRowComponentOptions; +export type MessageActionRowComponentResolvable = MessageActionRowComponent | ActionRowComponentOptions; export interface MessageActivity { partyId: string; @@ -4548,7 +4632,7 @@ export interface MessageCollectorOptions extends CollectorOptions<[Message]> { maxProcessed?: number; } -export type MessageComponent = Component | ActionRow | ButtonComponent | SelectMenuComponent; +export type MessageComponent = Component | ActionRow | ButtonComponent | SelectMenuComponent; export type MessageComponentCollectorOptions = Omit< InteractionCollectorOptions, @@ -4567,7 +4651,11 @@ export interface MessageEditOptions { files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; flags?: BitFieldResolvable; allowedMentions?: MessageMentionOptions; - components?: (ActionRow | (Required & ActionRowData))[]; + components?: ( + | ActionRow + | (Required & ActionRowData) + | APIActionRowComponent + )[]; } export interface MessageEvent { @@ -4605,9 +4693,9 @@ export interface MessageOptions { content?: string | null; embeds?: (Embed | APIEmbed)[]; components?: ( - | ActionRow - | (Required & ActionRowData) - | APIActionRowComponent + | ActionRow + | (Required & ActionRowData) + | APIActionRowComponent )[]; allowedMentions?: MessageMentionOptions; files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; @@ -4658,6 +4746,23 @@ export interface SelectMenuComponentOptionData { value: string; } +export interface TextInputComponentData extends BaseComponentData { + customId: string; + style: TextInputStyle; + label: string; + minLength?: number; + maxLength?: number; + required?: boolean; + value?: string; + placeholder?: string; +} + +export interface ModalData { + customId: string; + title: string; + components: ActionRowData[]; +} + export type MessageTarget = | Interaction | InteractionWebhook @@ -5156,6 +5261,7 @@ export { StageInstancePrivacyLevel, StickerType, StickerFormatType, + TextInputStyle, GuildSystemChannelFlags, ThreadMemberFlags, UserFlags, @@ -5166,7 +5272,8 @@ export { UnsafeSelectMenuComponent, SelectMenuOption, UnsafeSelectMenuOption, - ActionRowComponent, + MessageActionRowComponent, UnsafeEmbed, + ModalActionRowComponent, } from '@discordjs/builders'; export { DiscordAPIError, HTTPError, RateLimitError } from '@discordjs/rest'; diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 549f6f63a8e3..ea4df20d76ac 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -96,7 +96,7 @@ import { ActionRow, ButtonComponent, SelectMenuComponent, - ActionRowComponent, + MessageActionRowComponent, InteractionResponseFields, ThreadChannelType, Events, @@ -104,6 +104,7 @@ import { Status, CategoryChannelChildManager, ActionRowData, + MessageActionRowComponentData, } from '.'; import { expectAssignable, expectDeprecated, expectNotAssignable, expectNotType, expectType } from 'tsd'; import { Embed } from '@discordjs/builders'; @@ -723,11 +724,14 @@ client.on('interactionCreate', async interaction => { if (!interaction.isCommand()) return; - void new ActionRow(); + void new ActionRow(); const button = new ButtonComponent(); - const actionRow = new ActionRow({ type: ComponentType.ActionRow, components: [button.toJSON()] }); + const actionRow = new ActionRow({ + type: ComponentType.ActionRow, + components: [button.toJSON()], + }); await interaction.reply({ content: 'Hi!', components: [actionRow] }); @@ -1092,11 +1096,11 @@ client.on('interactionCreate', async interaction => { if (interaction.isMessageComponent()) { expectType(interaction); - expectType(interaction.component); + expectType(interaction.component); expectType(interaction.message); if (interaction.inCachedGuild()) { expectAssignable(interaction); - expectType(interaction.component); + expectType(interaction.component); expectType>(interaction.message); expectType(interaction.guild); expectAssignable>(interaction.reply({ fetchReply: true })); @@ -1108,7 +1112,7 @@ client.on('interactionCreate', async interaction => { expectType>(interaction.reply({ fetchReply: true })); } else if (interaction.inGuild()) { expectAssignable(interaction); - expectType(interaction.component); + expectType(interaction.component); expectType(interaction.message); expectType(interaction.guild); expectType>(interaction.reply({ fetchReply: true })); @@ -1336,7 +1340,7 @@ new ButtonComponent({ style: ButtonStyle.Danger, }); -expectNotAssignable({ +expectNotAssignable>({ type: ComponentType.ActionRow, components: [ { diff --git a/yarn.lock b/yarn.lock index 222cd3c8ce51..b86b35b5ad7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1764,7 +1764,7 @@ __metadata: "@typescript-eslint/eslint-plugin": ^5.11.0 "@typescript-eslint/parser": ^5.11.0 babel-plugin-transform-typescript-metadata: ^0.3.2 - discord-api-types: ^0.27.0 + discord-api-types: ^0.27.3 eslint: ^8.9.0 eslint-config-marine: ^9.3.2 eslint-config-prettier: ^8.3.0 @@ -4416,9 +4416,16 @@ __metadata: linkType: hard "discord-api-types@npm:^0.27.0": - version: 0.27.0 - resolution: "discord-api-types@npm:0.27.0" - checksum: 5a74a49ad7e57ea24e67d431de30cc7056d6d422b607c7d5a7dd35c683c8b87d70ec35a0d3929971adb411acc3df2bd6a77c1401ce30b29690bd1305e427265c + version: 0.27.1 + resolution: "discord-api-types@npm:0.27.1" + checksum: 5e3473eb01eb3e7ed2b1313513f165644dc70f1f64fb130a50b40394b41c97b1202f4de00b17df34a9f0916269595a091421955bb1e8dbd8e0475637512f2057 + languageName: node + linkType: hard + +"discord-api-types@npm:^0.27.3": + version: 0.27.3 + resolution: "discord-api-types@npm:0.27.3" + checksum: c22d87e787fae6cffd9d23972a3d196d4b43f2fb6deeed50181e7c9d4e823a4fd30a3e1d0e0b3b48a7c284ae2b39fbe960dee988375c7d4072df445f30ac440e languageName: node linkType: hard @@ -4433,7 +4440,7 @@ __metadata: "@sapphire/snowflake": ^3.1.0 "@types/node": ^16.11.24 "@types/ws": ^8.2.2 - discord-api-types: ^0.27.0 + discord-api-types: ^0.27.3 dtslint: ^4.2.1 eslint: ^8.9.0 eslint-config-prettier: ^8.3.0