diff --git a/packages/builders/__tests__/components/button.test.ts b/packages/builders/__tests__/components/button.test.ts index 524332b3d..47f829bfc 100644 --- a/packages/builders/__tests__/components/button.test.ts +++ b/packages/builders/__tests__/components/button.test.ts @@ -5,7 +5,7 @@ import { ComponentType, } from 'discord-api-types/v9'; import { buttonLabelValidator, buttonStyleValidator } from '../../src/components/Assertions'; -import { ButtonComponent } from '../../src/components/Button'; +import { ButtonComponent } from '../../src/components/button/Button'; const buttonComponent = () => new ButtonComponent(); diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts new file mode 100644 index 000000000..45827d543 --- /dev/null +++ b/packages/builders/src/components/button/Button.ts @@ -0,0 +1,42 @@ +import type { ButtonStyle, APIMessageComponentEmoji, APIButtonComponent } from 'discord-api-types/v9'; +import { + buttonLabelValidator, + buttonStyleValidator, + customIdValidator, + disabledValidator, + emojiValidator, + urlValidator, + validateRequiredButtonParameters, +} from '../Assertions'; +import { UnsafeButtonComponent } from './UnsafeButton'; + +export class ButtonComponent extends UnsafeButtonComponent { + public override setStyle(style: ButtonStyle) { + return super.setStyle(buttonStyleValidator.parse(style)); + } + + public override setURL(url: string) { + return super.setURL(urlValidator.parse(url)); + } + + public override setCustomId(customId: string) { + return super.setCustomId(customIdValidator.parse(customId)); + } + + public override setEmoji(emoji: APIMessageComponentEmoji) { + return super.setEmoji(emojiValidator.parse(emoji)); + } + + public override setDisabled(disabled: boolean) { + return super.setDisabled(disabledValidator.parse(disabled)); + } + + public override setLabel(label: string) { + return super.setLabel(buttonLabelValidator.parse(label)); + } + + public override toJSON(): APIButtonComponent { + validateRequiredButtonParameters(this.style, this.label, this.emoji, this.custom_id, this.url); + return super.toJSON(); + } +} diff --git a/packages/builders/src/components/Button.ts b/packages/builders/src/components/button/UnsafeButton.ts similarity index 75% rename from packages/builders/src/components/Button.ts rename to packages/builders/src/components/button/UnsafeButton.ts index c394f3b1d..182e72531 100644 --- a/packages/builders/src/components/Button.ts +++ b/packages/builders/src/components/button/UnsafeButton.ts @@ -1,16 +1,12 @@ -import { APIButtonComponent, APIMessageComponentEmoji, ButtonStyle, ComponentType } from 'discord-api-types/v9'; import { - buttonLabelValidator, - buttonStyleValidator, - customIdValidator, - disabledValidator, - emojiValidator, - urlValidator, - validateRequiredButtonParameters, -} from './Assertions'; -import type { Component } from './Component'; + ComponentType, + ButtonStyle, + type APIMessageComponentEmoji, + type APIButtonComponent, +} from 'discord-api-types/v9'; +import type { Component } from '../Component'; -export class ButtonComponent implements Component { +export class UnsafeButtonComponent implements Component { public readonly type = ComponentType.Button as const; public readonly style!: ButtonStyle; public readonly label?: string; @@ -41,7 +37,6 @@ export class ButtonComponent implements Component { * @param style The style of the button */ public setStyle(style: ButtonStyle) { - buttonStyleValidator.parse(style); Reflect.set(this, 'style', style); return this; } @@ -51,7 +46,6 @@ export class ButtonComponent implements Component { * @param url The URL to open when this button is clicked */ public setURL(url: string) { - urlValidator.parse(url); Reflect.set(this, 'url', url); return this; } @@ -61,7 +55,6 @@ export class ButtonComponent implements Component { * @param customId The custom ID to use for this button */ public setCustomId(customId: string) { - customIdValidator.parse(customId); Reflect.set(this, 'custom_id', customId); return this; } @@ -71,7 +64,6 @@ export class ButtonComponent implements Component { * @param emoji The emoji to display on this button */ public setEmoji(emoji: APIMessageComponentEmoji) { - emojiValidator.parse(emoji); Reflect.set(this, 'emoji', emoji); return this; } @@ -81,7 +73,6 @@ export class ButtonComponent implements Component { * @param disabled Whether or not to disable this button or not */ public setDisabled(disabled: boolean) { - disabledValidator.parse(disabled); Reflect.set(this, 'disabled', disabled); return this; } @@ -91,13 +82,11 @@ export class ButtonComponent implements Component { * @param label The label to display on this button */ public setLabel(label: string) { - buttonLabelValidator.parse(label); Reflect.set(this, 'label', label); return this; } public toJSON(): APIButtonComponent { - validateRequiredButtonParameters(this.style, this.label, this.emoji, this.custom_id, this.url); return { ...this, }; diff --git a/packages/builders/src/components/selectMenu/SelectMenu.ts b/packages/builders/src/components/selectMenu/SelectMenu.ts index 6146f0ad7..f5c45462f 100644 --- a/packages/builders/src/components/selectMenu/SelectMenu.ts +++ b/packages/builders/src/components/selectMenu/SelectMenu.ts @@ -1,4 +1,4 @@ -import { APISelectMenuComponent, ComponentType } from 'discord-api-types/v9'; +import type { APISelectMenuComponent } from 'discord-api-types/v9'; import { customIdValidator, disabledValidator, @@ -6,106 +6,34 @@ import { placeholderValidator, validateRequiredSelectMenuParameters, } from '../Assertions'; -import type { Component } from '../Component'; -import { SelectMenuOption } from './SelectMenuOption'; +import { UnsafeSelectMenuComponent } from './UnsafeSelectMenu'; /** * Represents a select menu component */ -export class SelectMenuComponent implements Component { - public readonly type = ComponentType.SelectMenu as const; - public readonly options: SelectMenuOption[]; - public readonly placeholder?: string; - public readonly min_values?: number; - public readonly max_values?: number; - public readonly custom_id!: string; - public readonly disabled?: boolean; - - public constructor(data?: APISelectMenuComponent & { type?: ComponentType.SelectMenu }) { - this.options = data?.options.map((option) => new SelectMenuOption(option)) ?? []; - this.placeholder = data?.placeholder; - this.min_values = data?.min_values; - this.max_values = data?.max_values; - /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ - this.custom_id = data?.custom_id as string; - /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ - this.disabled = data?.disabled; +export class SelectMenuComponent extends UnsafeSelectMenuComponent { + public override setPlaceholder(placeholder: string) { + return super.setPlaceholder(placeholderValidator.parse(placeholder)); } - /** - * Sets the placeholder for this select menu - * @param placeholder The placeholder to use for this select menu - */ - public setPlaceholder(placeholder: string) { - placeholderValidator.parse(placeholder); - Reflect.set(this, 'placeholder', placeholder); - return this; + public override setMinValues(minValues: number) { + return super.setMinValues(minMaxValidator.parse(minValues)); } - /** - * Sets the minimum values that must be selected in the select menu - * @param minValues The minimum values that must be selected - */ - public setMinValues(minValues: number) { - minMaxValidator.parse(minValues); - Reflect.set(this, 'min_values', minValues); - return this; + public override setMaxValues(maxValues: number) { + return super.setMaxValues(minMaxValidator.parse(maxValues)); } - /** - * Sets the maximum values that must be selected in the select menu - * @param minValues The maximum values that must be selected - */ - public setMaxValues(maxValues: number) { - minMaxValidator.parse(maxValues); - Reflect.set(this, 'max_values', maxValues); - return this; + public override setCustomId(customId: string) { + return super.setCustomId(customIdValidator.parse(customId)); } - /** - * Sets the custom Id for this select menu - * @param customId The custom ID to use for this select menu - */ - public setCustomId(customId: string) { - customIdValidator.parse(customId); - Reflect.set(this, 'custom_id', customId); - return this; + public override setDisabled(disabled: boolean) { + return super.setDisabled(disabledValidator.parse(disabled)); } - /** - * Sets whether or not this select menu is disabled - * @param disabled Whether or not this select menu is disabled - */ - public setDisabled(disabled: boolean) { - disabledValidator.parse(disabled); - Reflect.set(this, 'disabled', disabled); - return this; - } - - /** - * Adds options to this select menu - * @param options The options to add to this select menu - * @returns - */ - public addOptions(...options: SelectMenuOption[]) { - this.options.push(...options); - return this; - } - - /** - * Sets the options on this select menu - * @param options The options to set on this select menu - */ - public setOptions(options: SelectMenuOption[]) { - Reflect.set(this, 'options', [...options]); - return this; - } - - public toJSON(): APISelectMenuComponent { + public override toJSON(): APISelectMenuComponent { validateRequiredSelectMenuParameters(this.options, this.custom_id); - return { - ...this, - options: this.options.map((option) => option.toJSON()), - }; + return super.toJSON(); } } diff --git a/packages/builders/src/components/selectMenu/SelectMenuOption.ts b/packages/builders/src/components/selectMenu/SelectMenuOption.ts index 31b0ec9ca..7da5bddfe 100644 --- a/packages/builders/src/components/selectMenu/SelectMenuOption.ts +++ b/packages/builders/src/components/selectMenu/SelectMenuOption.ts @@ -5,79 +5,26 @@ import { labelValueValidator, validateRequiredSelectMenuOptionParameters, } from '../Assertions'; +import { UnsafeSelectMenuOption } from './UnsafeSelectMenuOption'; /** * Represents an option within a select menu component */ -export class SelectMenuOption { - public readonly label!: string; - public readonly value!: string; - public readonly description?: string; - public readonly emoji?: APIMessageComponentEmoji; - public readonly default?: boolean; - - public constructor(data?: APISelectMenuOption) { - /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ - this.label = data?.label as string; - this.value = data?.value as string; - /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ - this.description = data?.description; - this.emoji = data?.emoji; - this.default = data?.default; +export class SelectMenuOption extends UnsafeSelectMenuOption { + public override setDescription(description: string) { + return super.setDescription(labelValueValidator.parse(description)); } - /** - * Sets the label of this option - * @param label The label to show on this option - */ - public setLabel(label: string) { - Reflect.set(this, 'label', label); - return this; + public override setDefault(isDefault: boolean) { + return super.setDefault(defaultValidator.parse(isDefault)); } - /** - * Sets the value of this option - * @param value The value of this option - */ - public setValue(value: string) { - Reflect.set(this, 'value', value); - return this; + public override setEmoji(emoji: APIMessageComponentEmoji) { + return super.setEmoji(emojiValidator.parse(emoji)); } - /** - * Sets the description of this option. - * @param description The description of this option - */ - public setDescription(description: string) { - labelValueValidator.parse(description); - Reflect.set(this, 'description', description); - return this; - } - - /** - * Sets whether this option is selected by default - * @param isDefault Whether or not this option is selected by default - */ - public setDefault(isDefault: boolean) { - defaultValidator.parse(isDefault); - Reflect.set(this, 'default', isDefault); - return this; - } - - /** - * Sets the emoji to display on this button - * @param emoji The emoji to display on this button - */ - public setEmoji(emoji: APIMessageComponentEmoji) { - emojiValidator.parse(emoji); - Reflect.set(this, 'emoji', emoji); - return this; - } - - public toJSON(): APISelectMenuOption { + public override toJSON(): APISelectMenuOption { validateRequiredSelectMenuOptionParameters(this.label, this.value); - return { - ...this, - }; + return super.toJSON(); } } diff --git a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts new file mode 100644 index 000000000..990f78ab4 --- /dev/null +++ b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts @@ -0,0 +1,98 @@ +import { ComponentType, type APISelectMenuComponent } from 'discord-api-types/v9'; +import type { Component } from '../Component'; +import { SelectMenuOption } from './SelectMenuOption'; + +/** + * Represents a non-validated select menu component + */ +export class UnsafeSelectMenuComponent implements Component { + public readonly type = ComponentType.SelectMenu as const; + public readonly options: SelectMenuOption[]; + public readonly placeholder?: string; + public readonly min_values?: number; + public readonly max_values?: number; + public readonly custom_id!: string; + public readonly disabled?: boolean; + + public constructor(data?: APISelectMenuComponent) { + this.options = data?.options.map((option) => new SelectMenuOption(option)) ?? []; + this.placeholder = data?.placeholder; + this.min_values = data?.min_values; + this.max_values = data?.max_values; + /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ + this.custom_id = data?.custom_id as string; + /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ + this.disabled = data?.disabled; + } + + /** + * Sets the placeholder for this select menu + * @param placeholder The placeholder to use for this select menu + */ + public setPlaceholder(placeholder: string) { + Reflect.set(this, 'placeholder', placeholder); + return this; + } + + /** + * Sets thes minimum values that must be selected in the select menu + * @param minValues The minimum values that must be selected + */ + public setMinValues(minValues: number) { + Reflect.set(this, 'min_values', minValues); + return this; + } + + /** + * Sets thes maximum values that must be selected in the select menu + * @param minValues The maximum values that must be selected + */ + public setMaxValues(maxValues: number) { + Reflect.set(this, 'max_values', maxValues); + return this; + } + + /** + * Sets the custom Id for this select menu + * @param customId The custom ID to use for this select menu + */ + public setCustomId(customId: string) { + Reflect.set(this, 'custom_id', customId); + return this; + } + + /** + * Sets whether or not this select menu is disabled + * @param disabled Whether or not this select menu is disabled + */ + public setDisabled(disabled: boolean) { + Reflect.set(this, 'disabled', disabled); + return this; + } + + /** + * Adds options to this select menu + * @param options The options to add to this select menu + * @returns + */ + public addOptions(...options: SelectMenuOption[]) { + this.options.push(...options); + return this; + } + + /** + * Sets the options on this select menu + * @param options The options to set on this select menu + */ + public setOptions(options: SelectMenuOption[]) { + Reflect.set(this, 'options', [...options]); + return this; + } + + public toJSON(): APISelectMenuComponent { + return { + ...this, + options: this.options.map((option) => option.toJSON()), + }; + } +} diff --git a/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts b/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts new file mode 100644 index 000000000..a93b97321 --- /dev/null +++ b/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts @@ -0,0 +1,73 @@ +import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v9'; + +/** + * Represents a non-validated option within a select menu component + */ +export class UnsafeSelectMenuOption { + public readonly label!: string; + public readonly value!: string; + public readonly description?: string; + public readonly emoji?: APIMessageComponentEmoji; + public readonly default?: boolean; + + public constructor(data?: APISelectMenuOption) { + /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ + this.label = data?.label as string; + this.value = data?.value as string; + /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ + this.description = data?.description; + this.emoji = data?.emoji; + this.default = data?.default; + } + + /** + * Sets the label of this option + * @param label The label to show on this option + */ + public setLabel(label: string) { + Reflect.set(this, 'label', label); + return this; + } + + /** + * Sets the value of this option + * @param value The value of this option + */ + public setValue(value: string) { + Reflect.set(this, 'value', value); + return this; + } + + /** + * Sets the description of this option. + * @param description The description of this option + */ + public setDescription(description: string) { + Reflect.set(this, 'description', description); + return this; + } + + /** + * Sets whether this option is selected by default + * @param isDefault Whether or not this option is selected by default + */ + public setDefault(isDefault: boolean) { + Reflect.set(this, 'default', isDefault); + return this; + } + + /** + * Sets the emoji to display on this button + * @param emoji The emoji to display on this button + */ + public setEmoji(emoji: APIMessageComponentEmoji) { + Reflect.set(this, 'emoji', emoji); + return this; + } + + public toJSON(): APISelectMenuOption { + return { + ...this, + }; + } +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 3a4275b06..8cd32e589 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,14 +1,18 @@ export * as EmbedAssertions from './messages/embed/Assertions'; export * from './messages/embed/Embed'; export * from './messages/formatters'; +export * from './messages/embed/UnsafeEmbed'; export * as ComponentAssertions from './components/Assertions'; export * from './components/ActionRow'; -export * from './components/Button'; +export * from './components/button/Button'; export * from './components/Component'; export * from './components/Components'; export * from './components/selectMenu/SelectMenu'; export * from './components/selectMenu/SelectMenuOption'; +export * from './components/button/UnsafeButton'; +export * from './components/selectMenu/UnsafeSelectMenu'; +export * from './components/selectMenu/UnsafeSelectMenuOption'; export * as SlashCommandAssertions from './interactions/slashCommands/Assertions'; export * from './interactions/slashCommands/SlashCommandBuilder'; diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index d391f383e..3e0096db5 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -1,13 +1,4 @@ -import type { - APIEmbed, - APIEmbedAuthor, - APIEmbedField, - APIEmbedFooter, - APIEmbedImage, - APIEmbedProvider, - APIEmbedThumbnail, - APIEmbedVideo, -} from 'discord-api-types/v9'; +import type { APIEmbedField } from 'discord-api-types/v9'; import { authorNamePredicate, colorPredicate, @@ -22,300 +13,86 @@ import { urlPredicate, validateFieldLength, } from './Assertions'; - -export interface AuthorOptions { - name: string; - url?: string; - iconURL?: string; -} - -export interface FooterOptions { - text: string; - iconURL?: string; -} +import { AuthorOptions, FooterOptions, UnsafeEmbed } from './UnsafeEmbed'; /** * Represents an embed in a message (image/video preview, rich embed, etc.) */ -export class Embed implements APIEmbed { - /** - * An array of fields of this embed - */ - public readonly fields: APIEmbedField[]; - - /** - * The embed title - */ - public readonly title?: string; - - /** - * The embed description - */ - public readonly description?: string; - - /** - * The embed url - */ - public readonly url?: string; - - /** - * The embed color - */ - public readonly color?: number; - - /** - * The timestamp of the embed in the ISO format - */ - public readonly timestamp?: string; - - /** - * The embed thumbnail data - */ - public readonly thumbnail?: APIEmbedThumbnail; - - /** - * The embed image data - */ - public readonly image?: APIEmbedImage; - - /** - * Received video data - */ - public readonly video?: APIEmbedVideo; - - /** - * The embed author data - */ - public readonly author?: APIEmbedAuthor; - - /** - * Received data about the embed provider - */ - public readonly provider?: APIEmbedProvider; - - /** - * The embed footer data - */ - public readonly footer?: APIEmbedFooter; - - public constructor(data: APIEmbed = {}) { - this.title = data.title; - this.description = data.description; - this.url = data.url; - this.color = data.color; - this.thumbnail = data.thumbnail; - this.image = data.image; - this.video = data.video; - this.author = data.author; - this.provider = data.provider; - this.footer = data.footer; - this.fields = data.fields ?? []; - - if (data.timestamp) this.timestamp = new Date(data.timestamp).toISOString(); - } - - /** - * The accumulated length for the embed title, description, fields, footer text, and author name - */ - public get length(): number { - return ( - (this.title?.length ?? 0) + - (this.description?.length ?? 0) + - this.fields.reduce((prev, curr) => prev + curr.name.length + curr.value.length, 0) + - (this.footer?.text.length ?? 0) + - (this.author?.name.length ?? 0) - ); - } - - /** - * Adds a field to the embed (max 25) - * - * @param field The field to add. - */ - public addField(field: APIEmbedField): this { - return this.addFields(field); - } - - /** - * Adds fields to the embed (max 25) - * - * @param fields The fields to add - */ - public addFields(...fields: APIEmbedField[]): this { - // Data assertions - embedFieldsArrayPredicate.parse(fields); - +export class Embed extends UnsafeEmbed { + public override addFields(...fields: APIEmbedField[]): this { // Ensure adding these fields won't exceed the 25 field limit validateFieldLength(this.fields, fields.length); - this.fields.push(...Embed.normalizeFields(...fields)); - return this; + // Data assertions + return super.addFields(...embedFieldsArrayPredicate.parse(fields)); } - /** - * Removes, replaces, or inserts fields in the embed (max 25) - * - * @param index The index to start at - * @param deleteCount The number of fields to remove - * @param fields The replacing field objects - */ - public spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { - // Data assertions - embedFieldsArrayPredicate.parse(fields); - + public override spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { // Ensure adding these fields won't exceed the 25 field limit validateFieldLength(this.fields, fields.length - deleteCount); - this.fields.splice(index, deleteCount, ...Embed.normalizeFields(...fields)); - return this; + // Data assertions + return super.spliceFields(index, deleteCount, ...embedFieldsArrayPredicate.parse(fields)); } - /** - * Sets the embed's fields (max 25). - * @param fields The fields to set - */ - public setFields(...fields: APIEmbedField[]) { - this.spliceFields(0, this.fields.length, ...fields); - return this; - } - - /** - * Sets the author of this embed - * - * @param options The options for the author - */ - public setAuthor(options: AuthorOptions | null): this { + public override setAuthor(options: AuthorOptions | null): this { if (options === null) { - Reflect.set(this, 'author', undefined); - return this; + return super.setAuthor(null); } - const { name, iconURL, url } = options; // Data assertions - authorNamePredicate.parse(name); - urlPredicate.parse(iconURL); - urlPredicate.parse(url); + authorNamePredicate.parse(options.name); + urlPredicate.parse(options.iconURL); + urlPredicate.parse(options.url); - Reflect.set(this, 'author', { name, url, icon_url: iconURL }); - return this; + return super.setAuthor(options); } - /** - * Sets the color of this embed - * - * @param color The color of the embed - */ - public setColor(color: number | null): this { + public override setColor(color: number | null): this { // Data assertions - colorPredicate.parse(color); - - Reflect.set(this, 'color', color ?? undefined); - return this; + return super.setColor(colorPredicate.parse(color)); } - /** - * Sets the description of this embed - * - * @param description The description - */ - public setDescription(description: string | null): this { + public override setDescription(description: string | null): this { // Data assertions - descriptionPredicate.parse(description); - - Reflect.set(this, 'description', description ?? undefined); - return this; + return super.setDescription(descriptionPredicate.parse(description)); } - /** - * Sets the footer of this embed - * - * @param options The options for the footer - */ - public setFooter(options: FooterOptions | null): this { + public override setFooter(options: FooterOptions | null): this { if (options === null) { - Reflect.set(this, 'footer', undefined); - return this; + return super.setFooter(null); } - const { text, iconURL } = options; // Data assertions - footerTextPredicate.parse(text); - urlPredicate.parse(iconURL); + footerTextPredicate.parse(options.text); + urlPredicate.parse(options.iconURL); - Reflect.set(this, 'footer', { text, icon_url: iconURL }); - return this; + return super.setFooter(options); } - /** - * Sets the image of this embed - * - * @param url The URL of the image - */ - public setImage(url: string | null): this { + public override setImage(url: string | null): this { // Data assertions - urlPredicate.parse(url); - - Reflect.set(this, 'image', url ? { url } : undefined); - return this; + return super.setImage(urlPredicate.parse(url)!); } - /** - * Sets the thumbnail of this embed - * - * @param url The URL of the thumbnail - */ - public setThumbnail(url: string | null): this { + public override setThumbnail(url: string | null): this { // Data assertions - urlPredicate.parse(url); - - Reflect.set(this, 'thumbnail', url ? { url } : undefined); - return this; + return super.setThumbnail(urlPredicate.parse(url)!); } - /** - * Sets the timestamp of this embed - * - * @param timestamp The timestamp or date - */ - public setTimestamp(timestamp: number | Date | null = Date.now()): this { + public override setTimestamp(timestamp: number | Date | null = Date.now()): this { // Data assertions - timestampPredicate.parse(timestamp); - - Reflect.set(this, 'timestamp', timestamp ? new Date(timestamp).toISOString() : undefined); - return this; + return super.setTimestamp(timestampPredicate.parse(timestamp)); } - /** - * Sets the title of this embed - * - * @param title The title - */ - public setTitle(title: string | null): this { + public override setTitle(title: string | null): this { // Data assertions - titlePredicate.parse(title); - - Reflect.set(this, 'title', title ?? undefined); - return this; + return super.setTitle(titlePredicate.parse(title)); } - /** - * Sets the URL of this embed - * - * @param url The URL - */ - public setURL(url: string | null): this { + public override setURL(url: string | null): this { // Data assertions - urlPredicate.parse(url); - - Reflect.set(this, 'url', url ?? undefined); - return this; - } - - /** - * Transforms the embed to a plain object - */ - public toJSON(): APIEmbed { - return { ...this }; + return super.setURL(urlPredicate.parse(url)!); } /** @@ -323,7 +100,7 @@ export class Embed implements APIEmbed { * * @param fields Fields to normalize */ - public static normalizeFields(...fields: APIEmbedField[]): APIEmbedField[] { + public static override normalizeFields(...fields: APIEmbedField[]): APIEmbedField[] { return fields.flat(Infinity).map((field) => { fieldNamePredicate.parse(field.name); fieldValuePredicate.parse(field.value); diff --git a/packages/builders/src/messages/embed/UnsafeEmbed.ts b/packages/builders/src/messages/embed/UnsafeEmbed.ts new file mode 100644 index 000000000..6b7657398 --- /dev/null +++ b/packages/builders/src/messages/embed/UnsafeEmbed.ts @@ -0,0 +1,271 @@ +import type { + APIEmbed, + APIEmbedAuthor, + APIEmbedField, + APIEmbedFooter, + APIEmbedImage, + APIEmbedProvider, + APIEmbedThumbnail, + APIEmbedVideo, +} from 'discord-api-types/v9'; +import { Embed } from './Embed'; + +export interface AuthorOptions { + name: string; + url?: string; + iconURL?: string; +} + +export interface FooterOptions { + text: string; + iconURL?: string; +} + +export class UnsafeEmbed implements APIEmbed { + /** + * An array of fields of this embed + */ + public readonly fields: APIEmbedField[]; + + /** + * The embed title + */ + public readonly title?: string; + + /** + * The embed description + */ + public readonly description?: string; + + /** + * The embed url + */ + public readonly url?: string; + + /** + * The embed color + */ + public readonly color?: number; + + /** + * The timestamp of the embed in the ISO format + */ + public readonly timestamp?: string; + + /** + * The embed thumbnail data + */ + public readonly thumbnail?: APIEmbedThumbnail; + + /** + * The embed image data + */ + public readonly image?: APIEmbedImage; + + /** + * Received video data + */ + public readonly video?: APIEmbedVideo; + + /** + * The embed author data + */ + public readonly author?: APIEmbedAuthor; + + /** + * Received data about the embed provider + */ + public readonly provider?: APIEmbedProvider; + + /** + * The embed footer data + */ + public readonly footer?: APIEmbedFooter; + + public constructor(data: APIEmbed = {}) { + this.title = data.title; + this.description = data.description; + this.url = data.url; + this.color = data.color; + this.thumbnail = data.thumbnail; + this.image = data.image; + this.video = data.video; + this.author = data.author; + this.provider = data.provider; + this.footer = data.footer; + this.fields = data.fields ?? []; + + if (data.timestamp) this.timestamp = new Date(data.timestamp).toISOString(); + } + + /** + * The accumulated length for the embed title, description, fields, footer text, and author name + */ + public get length(): number { + return ( + (this.title?.length ?? 0) + + (this.description?.length ?? 0) + + this.fields.reduce((prev, curr) => prev + curr.name.length + curr.value.length, 0) + + (this.footer?.text.length ?? 0) + + (this.author?.name.length ?? 0) + ); + } + + /** + * Adds a field to the embed (max 25) + * + * @param field The field to add. + */ + public addField(field: APIEmbedField): this { + return this.addFields(field); + } + + /** + * Adds fields to the embed (max 25) + * + * @param fields The fields to add + */ + public addFields(...fields: APIEmbedField[]): this { + this.fields.push(...Embed.normalizeFields(...fields)); + return this; + } + + /** + * Removes, replaces, or inserts fields in the embed (max 25) + * + * @param index The index to start at + * @param deleteCount The number of fields to remove + * @param fields The replacing field objects + */ + public spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { + this.fields.splice(index, deleteCount, ...Embed.normalizeFields(...fields)); + return this; + } + + /** + * Sets the embed's fields (max 25). + * @param fields The fields to set + */ + public setFields(...fields: APIEmbedField[]) { + this.spliceFields(0, this.fields.length, ...fields); + return this; + } + + /** + * Sets the author of this embed + * + * @param options The options for the author + */ + public setAuthor(options: AuthorOptions | null): this { + if (options === null) { + Reflect.set(this, 'author', undefined); + return this; + } + + Reflect.set(this, 'author', { name: options.name, url: options.url, icon_url: options.iconURL }); + return this; + } + + /** + * Sets the color of this embed + * + * @param color The color of the embed + */ + public setColor(color: number | null): this { + Reflect.set(this, 'color', color ?? undefined); + return this; + } + + /** + * Sets the description of this embed + * + * @param description The description + */ + public setDescription(description: string | null): this { + Reflect.set(this, 'description', description ?? undefined); + return this; + } + + /** + * Sets the footer of this embed + * + * @param options The options for the footer + */ + public setFooter(options: FooterOptions | null): this { + if (options === null) { + Reflect.set(this, 'footer', undefined); + return this; + } + + Reflect.set(this, 'footer', { text: options.text, icon_url: options.iconURL }); + return this; + } + + /** + * Sets the image of this embed + * + * @param url The URL of the image + */ + public setImage(url: string | null): this { + Reflect.set(this, 'image', url ? { url } : undefined); + return this; + } + + /** + * Sets the thumbnail of this embed + * + * @param url The URL of the thumbnail + */ + public setThumbnail(url: string | null): this { + Reflect.set(this, 'thumbnail', url ? { url } : undefined); + return this; + } + + /** + * Sets the timestamp of this embed + * + * @param timestamp The timestamp or date + */ + public setTimestamp(timestamp: number | Date | null = Date.now()): this { + Reflect.set(this, 'timestamp', timestamp ? new Date(timestamp).toISOString() : undefined); + return this; + } + + /** + * Sets the title of this embed + * + * @param title The title + */ + public setTitle(title: string | null): this { + Reflect.set(this, 'title', title ?? undefined); + return this; + } + + /** + * Sets the URL of this embed + * + * @param url The URL + */ + public setURL(url: string | null): this { + Reflect.set(this, 'url', url ?? undefined); + return this; + } + + /** + * Transforms the embed to a plain object + */ + public toJSON(): APIEmbed { + return { ...this }; + } + + /** + * Normalizes field input and resolves strings + * + * @param fields Fields to normalize + */ + public static normalizeFields(...fields: APIEmbedField[]): APIEmbedField[] { + return fields + .flat(Infinity) + .map((field) => ({ name: field.name, value: field.value, inline: field.inline ?? undefined })); + } +} diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index 5f13aaadc..13270cd06 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -89,6 +89,7 @@ exports.CommandInteractionOptionResolver = require('./structures/CommandInteract exports.ContextMenuCommandInteraction = require('./structures/ContextMenuCommandInteraction'); exports.DMChannel = require('./structures/DMChannel'); exports.Embed = require('@discordjs/builders').Embed; +exports.UnsafeEmbed = require('@discordjs/builders').UnsafeEmbed; exports.Emoji = require('./structures/Emoji').Emoji; exports.Guild = require('./structures/Guild').Guild; exports.GuildAuditLogs = require('./structures/GuildAuditLogs'); @@ -182,8 +183,11 @@ exports.UserFlags = require('discord-api-types/v9').UserFlags; exports.WebhookType = require('discord-api-types/v9').WebhookType; exports.ActionRow = require('@discordjs/builders').ActionRow; exports.ButtonComponent = require('@discordjs/builders').ButtonComponent; +exports.UnsafeButtonComponent = require('@discordjs/builders').UnsafeButtonComponent; exports.SelectMenuComponent = require('@discordjs/builders').SelectMenuComponent; +exports.UnsafeSelectMenuComponent = require('@discordjs/builders').UnsafeButtonComponent; exports.SelectMenuOption = require('@discordjs/builders').SelectMenuOption; +exports.UnsafeSelectMenuOption = require('@discordjs/builders').UnsafeSelectMenuOption; exports.DiscordAPIError = require('@discordjs/rest').DiscordAPIError; exports.HTTPError = require('@discordjs/rest').HTTPError; exports.RateLimitError = require('@discordjs/rest').RateLimitError; diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index df55dc6ac..630e6f8fb 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -4615,8 +4615,6 @@ export interface MessageMentionOptions { export type MessageMentionTypes = 'roles' | 'users' | 'everyone'; -export { Embed }; - export interface MessageOptions { tts?: boolean; nonce?: string | number; @@ -5216,8 +5214,13 @@ export { export { ActionRow, ButtonComponent, + UnsafeButtonComponent, SelectMenuComponent, + UnsafeSelectMenuComponent, SelectMenuOption, + UnsafeSelectMenuOption, ActionRowComponent, + Embed, + UnsafeEmbed, } from '@discordjs/builders'; export { DiscordAPIError, HTTPError, RateLimitError } from '@discordjs/rest';