feat: Add Modals and Text Inputs (#7023)

Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Ryan Munro <monbrey@gmail.com>
Co-authored-by: Vitor <milagre.vitor@gmail.com>
This commit is contained in:
Suneet Tipirneni
2022-03-04 02:53:41 -05:00
committed by GitHub
parent 53defb82e3
commit ed92015634
31 changed files with 1075 additions and 115 deletions

View File

@@ -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<APIMessageComponent> = {
@@ -43,7 +49,7 @@ describe('Action Row Components', () => {
});
test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const actionRowData: APIActionRowComponent<APIMessageComponent> = {
const actionRowData: APIActionRowComponent<APIMessageActionRowComponent> = {
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<APIActionRowComponentTypes> = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
label: 'test',
custom_id: '123',
style: ButtonStyle.Primary,
},
],
};
const rowWithSelectMenuData: APIActionRowComponent<APIActionRowComponentTypes> = {
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')

View File

@@ -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);
});
});

View File

@@ -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<ModalActionRowComponent>().addComponents(
new TextInputComponent().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
)
.toJSON(),
).toEqual(modalData);
});
});

View File

@@ -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",

View File

@@ -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<MessageActionRowComponent>;
export type ModalComponent = ModalActionRowComponent | ActionRow<ModalActionRowComponent>;
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<T extends ActionRowComponent = ActionRowComponent> extends Component<
Omit<Partial<APIActionRowComponent<APIMessageComponent>> & { type: ComponentType.ActionRow }, 'components'>
export class ActionRow<
T extends ModalActionRowComponent | MessageActionRowComponent = ModalActionRowComponent | MessageActionRowComponent,
> extends Component<
Omit<
Partial<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>> & {
type: ComponentType.ActionRow;
},
'components'
>
> {
/**
* The components within this action row
*/
public readonly components: T[];
public constructor({ components, ...data }: Partial<APIActionRowComponent<APIMessageComponent>> = {}) {
public constructor({
components,
...data
}: Partial<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>> = {}) {
super({ type: ComponentType.ActionRow, ...data });
this.components = (components?.map((c) => createComponent(c)) ?? []) as T[];
}
@@ -44,14 +60,14 @@ export class ActionRow<T extends ActionRowComponent = ActionRowComponent> extend
return this;
}
public toJSON(): APIActionRowComponent<APIMessageComponent> {
public toJSON(): APIActionRowComponent<ReturnType<T['toJSON']>> {
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
components: this.components.map((component) => component.toJSON()) as ReturnType<T['toJSON']>[],
};
}
public equals(other: APIActionRowComponent<APIMessageComponent> | ActionRow) {
public equals(other: APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent> | ActionRow) {
if (other instanceof ActionRow) {
return isEqual(other.data, this.data) && isEqual(other.components, this.components);
}

View File

@@ -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<APIBaseComponent<ComponentType>> & {
type: ComponentType;
} = APIBaseComponent<ComponentType>,
> implements JSONEncodable<APIMessageComponent>, Equatable<Component | APIActionRowComponentTypes>
> implements
JSONEncodable<
| APIModalComponent
| APIMessageComponent
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>
>,
Equatable<
| Component
| APIActionRowComponentTypes
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>
>
{
/**
* The API data associated with this component
*/
public readonly data: DataType;
public abstract toJSON(): APIMessageComponent;
public abstract toJSON():
| APIActionRowComponentTypes
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>;
public abstract equals(other: Component | APIActionRowComponentTypes): boolean;
public abstract equals(
other:
| Component
| APIActionRowComponentTypes
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>,
): boolean;
public constructor(data: DataType) {
this.data = data;

View File

@@ -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<T extends keyof MappedComponentTypes>(
data: APIMessageComponent & { type: T },
data: (APIMessageComponent | APIModalComponent) & { type: T },
): MappedComponentTypes[T];
export function createComponent<C extends MessageComponent>(data: C): C;
export function createComponent(data: APIMessageComponent | MessageComponent): Component {
export function createComponent<C extends MessageComponent | ModalActionRowComponent>(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<ComponentType>).type}`);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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<APITextInputComponent> & { 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);
}
}

View File

@@ -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';

View File

@@ -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<ModalActionRowComponent>[],
) {
customIdValidator.parse(customId);
titleValidator.parse(title);
componentsValidator.parse(components);
}

View File

@@ -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();
}
}

View File

@@ -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<APIModalInteractionResponseCallbackData> {
protected readonly data: Partial<Omit<APIModalInteractionResponseCallbackData, 'components'>>;
public readonly components: ActionRow<ModalActionRowComponent>[] = [];
public constructor({ components, ...data }: Partial<APIModalInteractionResponseCallbackData> = {}) {
this.data = { ...data };
this.components = (components?.map((c) => createComponent(c)) ?? []) as ActionRow<ModalActionRowComponent>[];
}
/**
* 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<ModalActionRowComponent> | APIActionRowComponent<APIModalActionRowComponent>)[]
) {
this.components.push(
...components.map((component) =>
component instanceof ActionRow ? component : new ActionRow<ModalActionRowComponent>(component),
),
);
return this;
}
/**
* Sets the components in this modal
* @param components The components to set this modal to
*/
public setComponents(...components: ActionRow<ModalActionRowComponent>[]) {
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;
}
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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}.`,

View File

@@ -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;

View File

@@ -203,6 +203,7 @@ class CommandInteraction extends Interaction {
editReply() {}
deleteReply() {}
followUp() {}
showModal() {}
}
InteractionResponses.applyToClass(CommandInteraction, ['deferUpdate', 'update']);

View File

@@ -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}

View File

@@ -90,6 +90,7 @@ class MessageComponentInteraction extends Interaction {
followUp() {}
deferUpdate() {}
update() {}
showModal() {}
}
InteractionResponses.applyToClass(MessageComponentInteraction);

View File

@@ -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;

View File

@@ -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<ActionRow<ModalFieldData>>} The components in the modal
*/
this.components = components;
/**
* The extracted fields from the modal
* @type {Collection<string, ModalFieldData>} 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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<T extends MessageActionRowComponentData | ModalActionRowComponentData>
extends BaseComponentData {
components: T[];
}
export class ActionRow<T extends ActionRowComponent = ActionRowComponent> extends BuilderActionRow<T> {
export class ActionRow<
T extends MessageActionRowComponent | ModalActionRowComponent = MessageActionRowComponent,
> extends BuilderActionRow<T> {
constructor(
data?:
| ActionRowData
| (Omit<APIActionRowComponent<APIMessageComponent>, 'type'> & { type?: ComponentType.ActionRow }),
| ActionRowData<MessageActionRowComponentData | ModalActionRowComponentData>
| (Omit<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>, 'type'> & {
type?: ComponentType.ActionRow;
}),
);
}
@@ -336,6 +352,7 @@ export interface InteractionResponseFields<Cached extends CacheType = CacheType>
deferReply(options?: InteractionDeferReplyOptions): Promise<void>;
fetchReply(): Promise<GuildCacheMessage<Cached>>;
followUp(options: string | MessagePayload | InteractionReplyOptions): Promise<GuildCacheMessage<Cached>>;
showModal(modal: Modal): Promise<void>;
}
export abstract class CommandInteraction<Cached extends CacheType = CacheType> extends Interaction<Cached> {
@@ -374,6 +391,7 @@ export abstract class CommandInteraction<Cached extends CacheType = CacheType> e
public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise<GuildCacheMessage<Cached>>;
public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise<GuildCacheMessage<Cached>>;
public reply(options: string | MessagePayload | InteractionReplyOptions): Promise<void>;
public showModal(modal: Modal): Promise<void>;
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<Cached extends CacheType = CacheType> extends Base {
public isMessageComponent(): this is MessageComponentInteraction<Cached>;
public isSelectMenu(): this is SelectMenuInteraction<Cached>;
public isRepliable(): this is this & InteractionResponseFields<Cached>;
public isModalSubmit(): this is ModalSubmitInteraction<Cached>;
}
export class InteractionCollector<T extends Interaction> extends Collector<Snowflake, T> {
@@ -1447,17 +1474,19 @@ export class LimitedCollection<K, V> extends Collection<K, V> {
public keepOverLimit: ((value: V, key: K, collection: this) => boolean) | null;
}
export type MessageCollectorOptionsParams<T extends ComponentType, Cached extends boolean = boolean> =
export type MessageComponentType = Exclude<ComponentType, ComponentType.TextInput>;
export type MessageCollectorOptionsParams<T extends MessageComponentType, Cached extends boolean = boolean> =
| {
componentType?: T;
} & MessageComponentCollectorOptions<MappedInteractionTypes<Cached>[T]>;
export type MessageChannelCollectorOptionsParams<T extends ComponentType, Cached extends boolean = boolean> =
export type MessageChannelCollectorOptionsParams<T extends MessageComponentType, Cached extends boolean = boolean> =
| {
componentType?: T;
} & MessageChannelComponentCollectorOptions<MappedInteractionTypes<Cached>[T]>;
export type AwaitMessageCollectorOptionsParams<T extends ComponentType, Cached extends boolean = boolean> =
export type AwaitMessageCollectorOptionsParams<T extends MessageComponentType, Cached extends boolean = boolean> =
| { componentType?: T } & Pick<
InteractionCollectorOptions<MappedInteractionTypes<Cached>[T]>,
keyof AwaitMessageComponentOptions<any>
@@ -1490,7 +1519,7 @@ export class Message<Cached extends boolean = boolean> extends Base {
public get channel(): If<Cached, GuildTextBasedChannel, TextBasedChannel>;
public channelId: Snowflake;
public get cleanContent(): string;
public components: ActionRow<ActionRowComponent>[];
public components: ActionRow<MessageActionRowComponent>[];
public content: string;
public get createdAt(): Date;
public createdTimestamp: number;
@@ -1522,12 +1551,12 @@ export class Message<Cached extends boolean = boolean> extends Base {
public webhookId: Snowflake | null;
public flags: Readonly<MessageFlagsBitField>;
public reference: MessageReference | null;
public awaitMessageComponent<T extends ComponentType = ComponentType.ActionRow>(
public awaitMessageComponent<T extends MessageComponentType = ComponentType.ActionRow>(
options?: AwaitMessageCollectorOptionsParams<T, Cached>,
): Promise<MappedInteractionTypes<Cached>[T]>;
public awaitReactions(options?: AwaitReactionsOptions): Promise<Collection<Snowflake | string, MessageReaction>>;
public createReactionCollector(options?: ReactionCollectorOptions): ReactionCollector;
public createMessageComponentCollector<T extends ComponentType = ComponentType.ActionRow>(
public createMessageComponentCollector<T extends MessageComponentType = ComponentType.ActionRow>(
options?: MessageCollectorOptionsParams<T, Cached>,
): InteractionCollector<MappedInteractionTypes<Cached>[T]>;
public delete(): Promise<Message>;
@@ -1541,7 +1570,7 @@ export class Message<Cached extends boolean = boolean> extends Base {
public react(emoji: EmojiIdentifierResolvable): Promise<MessageReaction>;
public removeAttachments(): Promise<Message>;
public reply(options: string | MessagePayload | ReplyMessageOptions): Promise<Message>;
public resolveComponent(customId: string): ActionRowComponent | null;
public resolveComponent(customId: string): MessageActionRowComponent | null;
public startThread(options: StartThreadOptions): Promise<ThreadChannel>;
public suppressEmbeds(suppress?: boolean): Promise<Message>;
public toJSON(): unknown;
@@ -1590,10 +1619,10 @@ export class MessageComponentInteraction<Cached extends CacheType = CacheType> e
protected constructor(client: Client, data: RawMessageComponentInteractionData);
public get component(): CacheTypeReducer<
Cached,
ActionRowComponent,
Exclude<APIMessageComponent, APIActionRowComponent<APIMessageComponent>>,
ActionRowComponent | Exclude<APIMessageComponent, APIActionRowComponent<APIMessageComponent>>,
ActionRowComponent | Exclude<APIMessageComponent, APIActionRowComponent<APIMessageComponent>>
MessageActionRowComponent,
Exclude<APIMessageComponent, APIActionRowComponent<APIMessageActionRowComponent>>,
MessageActionRowComponent | Exclude<APIMessageComponent, APIActionRowComponent<APIMessageActionRowComponent>>,
MessageActionRowComponent | Exclude<APIMessageComponent, APIActionRowComponent<APIMessageActionRowComponent>>
>;
public componentType: Exclude<ComponentType, ComponentType.ActionRow>;
public customId: string;
@@ -1618,6 +1647,7 @@ export class MessageComponentInteraction<Cached extends CacheType = CacheType> e
public reply(options: string | MessagePayload | InteractionReplyOptions): Promise<void>;
public update(options: InteractionUpdateOptions & { fetchReply: true }): Promise<GuildCacheMessage<Cached>>;
public update(options: string | MessagePayload | InteractionUpdateOptions): Promise<void>;
public showModal(modal: Modal): Promise<void>;
}
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<string, ModalFieldData>;
public getField(customId: string): ModalFieldData;
public getTextInputValue(customId: string): string;
}
export interface ModalMessageModalSubmitInteraction<Cached extends CacheType = CacheType>
extends ModalSubmitInteraction<Cached> {
message: GuildCacheMessage<Cached> | null;
update(options: InteractionUpdateOptions & { fetchReply: true }): Promise<GuildCacheMessage<Cached>>;
update(options: string | MessagePayload | InteractionUpdateOptions): Promise<void>;
deferUpdate(options: InteractionDeferUpdateOptions & { fetchReply: true }): Promise<GuildCacheMessage<Cached>>;
deferUpdate(options?: InteractionDeferUpdateOptions): Promise<void>;
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<Cached extends CacheType = CacheType> extends Interaction<Cached> {
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<GuildCacheMessage<Cached>>;
public reply(options: string | MessagePayload | InteractionReplyOptions): Promise<void>;
public deleteReply(): Promise<void>;
public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise<GuildCacheMessage<Cached>>;
public deferReply(options: InteractionDeferReplyOptions & { fetchReply: true }): Promise<GuildCacheMessage<Cached>>;
public deferReply(options?: InteractionDeferReplyOptions): Promise<void>;
public fetchReply(): Promise<GuildCacheMessage<Cached>>;
public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise<GuildCacheMessage<Cached>>;
public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>;
public inCachedGuild(): this is ModalSubmitInteraction<'cached'>;
public inRawGuild(): this is ModalSubmitInteraction<'raw'>;
public isFromMessage(): this is ModalMessageModalSubmitInteraction<Cached>;
}
export class NewsChannel extends BaseGuildTextChannel {
public threads: ThreadManager<AllowedThreadTypeForNewsChannel>;
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<MessageActionRowComponentData | ModalActionRowComponentData>;
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<T extends ComponentType = ComponentType.ActionRow>(
readonly lastPinAt: Date | null;
awaitMessageComponent<T extends MessageComponentType = ComponentType.ActionRow>(
options?: AwaitMessageCollectorOptionsParams<T, true>,
): Promise<MappedInteractionTypes[T]>;
awaitMessages(options?: AwaitMessagesOptions): Promise<Collection<Snowflake, Message>>;
@@ -3141,7 +3225,7 @@ export interface TextBasedChannelFields extends PartialTextBasedChannelFields {
messages: Collection<Snowflake, Message> | readonly MessageResolvable[] | number,
filterOld?: boolean,
): Promise<Collection<Snowflake, Message>>;
createMessageComponentCollector<T extends ComponentType = ComponentType.ActionRow>(
createMessageComponentCollector<T extends MessageComponentType = ComponentType.ActionRow>(
options?: MessageChannelCollectorOptionsParams<T, true>,
): InteractionCollector<MappedInteractionTypes[T]>;
createMessageCollector(options?: MessageCollectorOptions): MessageCollector;
@@ -4518,7 +4602,7 @@ export type ActionRowComponentOptions =
| (Required<BaseComponentData> & ButtonComponentData)
| (Required<BaseComponentData> & 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<ActionRowComponent> | ButtonComponent | SelectMenuComponent;
export type MessageComponent = Component | ActionRow<MessageActionRowComponent> | ButtonComponent | SelectMenuComponent;
export type MessageComponentCollectorOptions<T extends MessageComponentInteraction> = Omit<
InteractionCollectorOptions<T>,
@@ -4567,7 +4651,11 @@ export interface MessageEditOptions {
files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[];
flags?: BitFieldResolvable<MessageFlagsString, number>;
allowedMentions?: MessageMentionOptions;
components?: (ActionRow<ActionRowComponent> | (Required<BaseComponentData> & ActionRowData))[];
components?: (
| ActionRow<MessageActionRowComponent>
| (Required<BaseComponentData> & ActionRowData<MessageActionRowComponentData>)
| APIActionRowComponent<APIMessageActionRowComponent>
)[];
}
export interface MessageEvent {
@@ -4605,9 +4693,9 @@ export interface MessageOptions {
content?: string | null;
embeds?: (Embed | APIEmbed)[];
components?: (
| ActionRow<ActionRowComponent>
| (Required<BaseComponentData> & ActionRowData)
| APIActionRowComponent<APIActionRowComponentTypes>
| ActionRow<MessageActionRowComponent>
| (Required<BaseComponentData> & ActionRowData<MessageActionRowComponentData>)
| APIActionRowComponent<APIMessageActionRowComponent>
)[];
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<ModalActionRowComponentData>[];
}
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';

View File

@@ -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<ActionRowComponent>();
void new ActionRow<MessageActionRowComponent>();
const button = new ButtonComponent();
const actionRow = new ActionRow<ActionRowComponent>({ type: ComponentType.ActionRow, components: [button.toJSON()] });
const actionRow = new ActionRow<MessageActionRowComponent>({
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<MessageComponentInteraction>(interaction);
expectType<ActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
expectType<MessageActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
expectType<Message | APIMessage>(interaction.message);
if (interaction.inCachedGuild()) {
expectAssignable<MessageComponentInteraction>(interaction);
expectType<ActionRowComponent>(interaction.component);
expectType<MessageActionRowComponent>(interaction.component);
expectType<Message<true>>(interaction.message);
expectType<Guild>(interaction.guild);
expectAssignable<Promise<Message>>(interaction.reply({ fetchReply: true }));
@@ -1108,7 +1112,7 @@ client.on('interactionCreate', async interaction => {
expectType<Promise<APIMessage>>(interaction.reply({ fetchReply: true }));
} else if (interaction.inGuild()) {
expectAssignable<MessageComponentInteraction>(interaction);
expectType<ActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
expectType<MessageActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
expectType<Message | APIMessage>(interaction.message);
expectType<Guild | null>(interaction.guild);
expectType<Promise<APIMessage | Message>>(interaction.reply({ fetchReply: true }));
@@ -1336,7 +1340,7 @@ new ButtonComponent({
style: ButtonStyle.Danger,
});
expectNotAssignable<ActionRowData>({
expectNotAssignable<ActionRowData<MessageActionRowComponentData>>({
type: ComponentType.ActionRow,
components: [
{

View File

@@ -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