refactor: builders (#10448)

BREAKING CHANGE: formatters export removed (prev. deprecated)
BREAKING CHANGE: `SelectMenuBuilder` and `SelectMenuOptionBuilder` have been removed (prev. deprecated)
BREAKING CHANGE: `EmbedBuilder` no longer takes camalCase options
BREAKING CHANGE: `ActionRowBuilder` now has specialized `[add/set]X` methods as opposed to the current `[add/set]Components`
BREAKING CHANGE: Removed `equals` methods
BREAKING CHANGE: Sapphire -> zod for validation
BREAKING CHANGE: Removed the ability to pass `null`/`undefined` to clear fields, use `clearX()` instead
BREAKING CHANGE: Renamed all "slash command" symbols to instead use "chat input command"
BREAKING CHANGE: Removed `ContextMenuCommandBuilder` in favor of `MessageCommandBuilder` and `UserCommandBuilder`
BREAKING CHANGE: Removed support for passing the "string key"s of enums
BREAKING CHANGE: Removed `Button` class in favor for specialized classes depending on the style
BREAKING CHANGE: Removed nested `addX` styled-methods in favor of plural `addXs`

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: Almeida <github@almeidx.dev>
This commit is contained in:
Denis Cristea
2024-10-01 19:11:56 +03:00
committed by GitHub
parent c633d5c7f6
commit ab32f26cbb
91 changed files with 3772 additions and 3824 deletions

View File

@@ -1,99 +1,70 @@
import { s } from '@sapphire/shapeshift';
import type { APIEmbedField } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { z } from 'zod';
import { refineURLPredicate } from '../../Assertions.js';
import { embedLength } from '../../util/componentUtil.js';
export const fieldNamePredicate = s
const namePredicate = z.string().min(1).max(256);
const iconURLPredicate = z
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(256)
.setValidationEnabled(isValidationEnabled);
.url()
.refine(refineURLPredicate(['http:', 'https:', 'attachment:']), {
message: 'Invalid protocol for icon URL. Must be http:, https:, or attachment:',
});
export const fieldValuePredicate = s
const URLPredicate = z
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(1_024)
.setValidationEnabled(isValidationEnabled);
.url()
.refine(refineURLPredicate(['http:', 'https:']), { message: 'Invalid protocol for URL. Must be http: or https:' });
export const fieldInlinePredicate = s.boolean().optional();
export const embedFieldPredicate = z.object({
name: namePredicate,
value: z.string().min(1).max(1_024),
inline: z.boolean().optional(),
});
export const embedFieldPredicate = s
export const embedAuthorPredicate = z.object({
name: namePredicate,
icon_url: iconURLPredicate.optional(),
url: URLPredicate.optional(),
});
export const embedFooterPredicate = z.object({
text: z.string().min(1).max(2_048),
icon_url: iconURLPredicate.optional(),
});
export const embedPredicate = z
.object({
name: fieldNamePredicate,
value: fieldValuePredicate,
inline: fieldInlinePredicate,
title: namePredicate.optional(),
description: z.string().min(1).max(4_096).optional(),
url: URLPredicate.optional(),
timestamp: z.string().optional(),
color: z.number().int().min(0).max(0xffffff).optional(),
footer: embedFooterPredicate.optional(),
image: z.object({ url: URLPredicate }).optional(),
thumbnail: z.object({ url: URLPredicate }).optional(),
author: embedAuthorPredicate.optional(),
fields: z.array(embedFieldPredicate).max(25).optional(),
})
.setValidationEnabled(isValidationEnabled);
export const embedFieldsArrayPredicate = embedFieldPredicate.array().setValidationEnabled(isValidationEnabled);
export const fieldLengthPredicate = s.number().lessThanOrEqual(25).setValidationEnabled(isValidationEnabled);
export function validateFieldLength(amountAdding: number, fields?: APIEmbedField[]): void {
fieldLengthPredicate.parse((fields?.length ?? 0) + amountAdding);
}
export const authorNamePredicate = fieldNamePredicate.nullable().setValidationEnabled(isValidationEnabled);
export const imageURLPredicate = s
.string()
.url({
allowedProtocols: ['http:', 'https:', 'attachment:'],
})
.nullish()
.setValidationEnabled(isValidationEnabled);
export const urlPredicate = s
.string()
.url({
allowedProtocols: ['http:', 'https:'],
})
.nullish()
.setValidationEnabled(isValidationEnabled);
export const embedAuthorPredicate = s
.object({
name: authorNamePredicate,
iconURL: imageURLPredicate,
url: urlPredicate,
})
.setValidationEnabled(isValidationEnabled);
export const RGBPredicate = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(255)
.setValidationEnabled(isValidationEnabled);
export const colorPredicate = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(0xffffff)
.or(s.tuple([RGBPredicate, RGBPredicate, RGBPredicate]))
.nullable()
.setValidationEnabled(isValidationEnabled);
export const descriptionPredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(4_096)
.nullable()
.setValidationEnabled(isValidationEnabled);
export const footerTextPredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(2_048)
.nullable()
.setValidationEnabled(isValidationEnabled);
export const embedFooterPredicate = s
.object({
text: footerTextPredicate,
iconURL: imageURLPredicate,
})
.setValidationEnabled(isValidationEnabled);
export const timestampPredicate = s.union([s.number(), s.date()]).nullable().setValidationEnabled(isValidationEnabled);
export const titlePredicate = fieldNamePredicate.nullable().setValidationEnabled(isValidationEnabled);
.refine(
(embed) => {
return (
embed.title !== undefined ||
embed.description !== undefined ||
(embed.fields !== undefined && embed.fields.length > 0) ||
embed.footer !== undefined ||
embed.author !== undefined ||
embed.image !== undefined ||
embed.thumbnail !== undefined
);
},
{
message: 'Embed must have at least a title, description, a field, a footer, an author, an image, OR a thumbnail.',
},
)
.refine(
(embed) => {
return embedLength(embed) <= 6_000;
},
{ message: 'Embeds must not exceed 6000 characters in total.' },
);

View File

@@ -1,77 +1,38 @@
import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, APIEmbedImage } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import {
colorPredicate,
descriptionPredicate,
embedAuthorPredicate,
embedFieldsArrayPredicate,
embedFooterPredicate,
imageURLPredicate,
timestampPredicate,
titlePredicate,
urlPredicate,
validateFieldLength,
} from './Assertions.js';
import type { JSONEncodable } from '@discordjs/util';
import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter } from 'discord-api-types/v10';
import type { RestOrArray } from '../../util/normalizeArray.js';
import { normalizeArray } from '../../util/normalizeArray.js';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { isValidationEnabled } from '../../util/validation.js';
import { embedPredicate } from './Assertions.js';
import { EmbedAuthorBuilder } from './EmbedAuthor.js';
import { EmbedFieldBuilder } from './EmbedField.js';
import { EmbedFooterBuilder } from './EmbedFooter.js';
/**
* A tuple satisfying the RGB color model.
*
* @see {@link https://developer.mozilla.org/docs/Glossary/RGB}
* Data stored in the process of constructing an embed.
*/
export type RGBTuple = [red: number, green: number, blue: number];
/**
* The base icon data typically used in payloads.
*/
export interface IconData {
/**
* The URL of the icon.
*/
iconURL?: string;
/**
* The proxy URL of the icon.
*/
proxyIconURL?: string;
}
/**
* Represents the author data of an embed.
*/
export interface EmbedAuthorData extends IconData, Omit<APIEmbedAuthor, 'icon_url' | 'proxy_icon_url'> {}
/**
* Represents the author options of an embed.
*/
export interface EmbedAuthorOptions extends Omit<EmbedAuthorData, 'proxyIconURL'> {}
/**
* Represents the footer data of an embed.
*/
export interface EmbedFooterData extends IconData, Omit<APIEmbedFooter, 'icon_url' | 'proxy_icon_url'> {}
/**
* Represents the footer options of an embed.
*/
export interface EmbedFooterOptions extends Omit<EmbedFooterData, 'proxyIconURL'> {}
/**
* Represents the image data of an embed.
*/
export interface EmbedImageData extends Omit<APIEmbedImage, 'proxy_url'> {
/**
* The proxy URL for the image.
*/
proxyURL?: string;
export interface EmbedBuilderData extends Omit<APIEmbed, 'author' | 'fields' | 'footer'> {
author?: EmbedAuthorBuilder;
fields: EmbedFieldBuilder[];
footer?: EmbedFooterBuilder;
}
/**
* A builder that creates API-compatible JSON data for embeds.
*/
export class EmbedBuilder {
export class EmbedBuilder implements JSONEncodable<APIEmbed> {
/**
* The API data associated with this embed.
*/
public readonly data: APIEmbed;
private readonly data: EmbedBuilderData;
/**
* Gets the fields of this embed.
*/
public get fields(): readonly EmbedFieldBuilder[] {
return this.data.fields;
}
/**
* Creates a new embed from API data.
@@ -79,8 +40,12 @@ export class EmbedBuilder {
* @param data - The API data to create this embed with
*/
public constructor(data: APIEmbed = {}) {
this.data = { ...data };
if (data.timestamp) this.data.timestamp = new Date(data.timestamp).toISOString();
this.data = {
...structuredClone(data),
author: data.author && new EmbedAuthorBuilder(data.author),
fields: data.fields?.map((field) => new EmbedFieldBuilder(field)) ?? [],
footer: data.footer && new EmbedFooterBuilder(data.footer),
};
}
/**
@@ -107,16 +72,13 @@ export class EmbedBuilder {
* ```
* @param fields - The fields to add
*/
public addFields(...fields: RestOrArray<APIEmbedField>): this {
public addFields(
...fields: RestOrArray<APIEmbedField | EmbedFieldBuilder | ((builder: EmbedFieldBuilder) => EmbedFieldBuilder)>
): this {
const normalizedFields = normalizeArray(fields);
// Ensure adding these fields won't exceed the 25 field limit
validateFieldLength(normalizedFields.length, this.data.fields);
const resolved = normalizedFields.map((field) => resolveBuilder(field, EmbedFieldBuilder));
// Data assertions
embedFieldsArrayPredicate.parse(normalizedFields);
if (this.data.fields) this.data.fields.push(...normalizedFields);
else this.data.fields = normalizedFields;
this.data.fields.push(...resolved);
return this;
}
@@ -149,14 +111,14 @@ export class EmbedBuilder {
* @param deleteCount - The number of fields to remove
* @param fields - The replacing field objects
*/
public spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this {
// Ensure adding these fields won't exceed the 25 field limit
validateFieldLength(fields.length - deleteCount, this.data.fields);
public spliceFields(
index: number,
deleteCount: number,
...fields: (APIEmbedField | EmbedFieldBuilder | ((builder: EmbedFieldBuilder) => EmbedFieldBuilder))[]
): this {
const resolved = fields.map((field) => resolveBuilder(field, EmbedFieldBuilder));
this.data.fields.splice(index, deleteCount, ...resolved);
// Data assertions
embedFieldsArrayPredicate.parse(fields);
if (this.data.fields) this.data.fields.splice(index, deleteCount, ...fields);
else this.data.fields = fields;
return this;
}
@@ -170,8 +132,10 @@ export class EmbedBuilder {
* You can set a maximum of 25 fields.
* @param fields - The fields to set
*/
public setFields(...fields: RestOrArray<APIEmbedField>): this {
this.spliceFields(0, this.data.fields?.length ?? 0, ...normalizeArray(fields));
public setFields(
...fields: RestOrArray<APIEmbedField | EmbedFieldBuilder | ((builder: EmbedFieldBuilder) => EmbedFieldBuilder)>
): this {
this.spliceFields(0, this.data.fields.length, ...normalizeArray(fields));
return this;
}
@@ -180,17 +144,28 @@ export class EmbedBuilder {
*
* @param options - The options to use
*/
public setAuthor(
options: APIEmbedAuthor | EmbedAuthorBuilder | ((builder: EmbedAuthorBuilder) => EmbedAuthorBuilder),
): this {
this.data.author = resolveBuilder(options, EmbedAuthorBuilder);
return this;
}
public setAuthor(options: EmbedAuthorOptions | null): this {
if (options === null) {
this.data.author = undefined;
return this;
}
/**
* Updates the author of this embed (and creates it if it doesn't exist).
*
* @param updater - The function to update the author with
*/
public updateAuthor(updater: (builder: EmbedAuthorBuilder) => void) {
updater((this.data.author ??= new EmbedAuthorBuilder()));
return this;
}
// Data assertions
embedAuthorPredicate.parse(options);
this.data.author = { name: options.name, url: options.url, icon_url: options.iconURL };
/**
* Clears the author of this embed.
*/
public clearAuthor(): this {
this.data.author = undefined;
return this;
}
@@ -199,17 +174,16 @@ export class EmbedBuilder {
*
* @param color - The color to use
*/
public setColor(color: RGBTuple | number | null): this {
// Data assertions
colorPredicate.parse(color);
public setColor(color: number): this {
this.data.color = color;
return this;
}
if (Array.isArray(color)) {
const [red, green, blue] = color;
this.data.color = (red << 16) + (green << 8) + blue;
return this;
}
this.data.color = color ?? undefined;
/**
* Clears the color of this embed.
*/
public clearColor(): this {
this.data.color = undefined;
return this;
}
@@ -218,11 +192,16 @@ export class EmbedBuilder {
*
* @param description - The description to use
*/
public setDescription(description: string | null): this {
// Data assertions
descriptionPredicate.parse(description);
public setDescription(description: string): this {
this.data.description = description;
return this;
}
this.data.description = description ?? undefined;
/**
* Clears the description of this embed.
*/
public clearDescription(): this {
this.data.description = undefined;
return this;
}
@@ -231,16 +210,28 @@ export class EmbedBuilder {
*
* @param options - The footer to use
*/
public setFooter(options: EmbedFooterOptions | null): this {
if (options === null) {
this.data.footer = undefined;
return this;
}
public setFooter(
options: APIEmbedFooter | EmbedFooterBuilder | ((builder: EmbedFooterBuilder) => EmbedFooterBuilder),
): this {
this.data.footer = resolveBuilder(options, EmbedFooterBuilder);
return this;
}
// Data assertions
embedFooterPredicate.parse(options);
/**
* Updates the footer of this embed (and creates it if it doesn't exist).
*
* @param updater - The function to update the footer with
*/
public updateFooter(updater: (builder: EmbedFooterBuilder) => void) {
updater((this.data.footer ??= new EmbedFooterBuilder()));
return this;
}
this.data.footer = { text: options.text, icon_url: options.iconURL };
/**
* Clears the footer of this embed.
*/
public clearFooter(): this {
this.data.footer = undefined;
return this;
}
@@ -249,11 +240,16 @@ export class EmbedBuilder {
*
* @param url - The image URL to use
*/
public setImage(url: string | null): this {
// Data assertions
imageURLPredicate.parse(url);
public setImage(url: string): this {
this.data.image = { url };
return this;
}
this.data.image = url ? { url } : undefined;
/**
* Clears the image of this embed.
*/
public clearImage(): this {
this.data.image = undefined;
return this;
}
@@ -262,11 +258,16 @@ export class EmbedBuilder {
*
* @param url - The thumbnail URL to use
*/
public setThumbnail(url: string | null): this {
// Data assertions
imageURLPredicate.parse(url);
public setThumbnail(url: string): this {
this.data.thumbnail = { url };
return this;
}
this.data.thumbnail = url ? { url } : undefined;
/**
* Clears the thumbnail of this embed.
*/
public clearThumbnail(): this {
this.data.thumbnail = undefined;
return this;
}
@@ -275,11 +276,16 @@ export class EmbedBuilder {
*
* @param timestamp - The timestamp or date to use
*/
public setTimestamp(timestamp: Date | number | null = Date.now()): this {
// Data assertions
timestampPredicate.parse(timestamp);
public setTimestamp(timestamp: Date | number | string = Date.now()): this {
this.data.timestamp = new Date(timestamp).toISOString();
return this;
}
this.data.timestamp = timestamp ? new Date(timestamp).toISOString() : undefined;
/**
* Clears the timestamp of this embed.
*/
public clearTimestamp(): this {
this.data.timestamp = undefined;
return this;
}
@@ -288,11 +294,16 @@ export class EmbedBuilder {
*
* @param title - The title to use
*/
public setTitle(title: string | null): this {
// Data assertions
titlePredicate.parse(title);
public setTitle(title: string): this {
this.data.title = title;
return this;
}
this.data.title = title ?? undefined;
/**
* Clears the title of this embed.
*/
public clearTitle(): this {
this.data.title = undefined;
return this;
}
@@ -301,22 +312,41 @@ export class EmbedBuilder {
*
* @param url - The URL to use
*/
public setURL(url: string | null): this {
// Data assertions
urlPredicate.parse(url);
public setURL(url: string): this {
this.data.url = url;
return this;
}
this.data.url = url ?? undefined;
/**
* Clears the URL of this embed.
*/
public clearURL(): this {
this.data.url = undefined;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(): APIEmbed {
return { ...this.data };
public toJSON(validationOverride?: boolean): APIEmbed {
const { author, fields, footer, ...rest } = this.data;
const data = {
...structuredClone(rest),
// Disable validation because the embedPredicate below will validate those as well
author: this.data.author?.toJSON(false),
fields: this.data.fields?.map((field) => field.toJSON(false)),
footer: this.data.footer?.toJSON(false),
};
if (validationOverride ?? isValidationEnabled()) {
embedPredicate.parse(data);
}
return data;
}
}

View File

@@ -0,0 +1,82 @@
import type { APIEmbedAuthor } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { embedAuthorPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for the embed author.
*/
export class EmbedAuthorBuilder {
private readonly data: Partial<APIEmbedAuthor>;
/**
* Creates a new embed author from API data.
*
* @param data - The API data to use
*/
public constructor(data?: Partial<APIEmbedAuthor>) {
this.data = structuredClone(data) ?? {};
}
/**
* Sets the name for this embed author.
*
* @param name - The name to use
*/
public setName(name: string): this {
this.data.name = name;
return this;
}
/**
* Sets the URL for this embed author.
*
* @param url - The url to use
*/
public setURL(url: string): this {
this.data.url = url;
return this;
}
/**
* Clears the URL for this embed author.
*/
public clearURL(): this {
this.data.url = undefined;
return this;
}
/**
* Sets the icon URL for this embed author.
*
* @param iconURL - The icon URL to use
*/
public setIconURL(iconURL: string): this {
this.data.icon_url = iconURL;
return this;
}
/**
* Clears the icon URL for this embed author.
*/
public clearIconURL(): this {
this.data.icon_url = undefined;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIEmbedAuthor {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
embedAuthorPredicate.parse(clone);
}
return clone as APIEmbedAuthor;
}
}

View File

@@ -0,0 +1,66 @@
import type { APIEmbedField } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { embedFieldPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for embed fields.
*/
export class EmbedFieldBuilder {
private readonly data: Partial<APIEmbedField>;
/**
* Creates a new embed field from API data.
*
* @param data - The API data to use
*/
public constructor(data?: Partial<APIEmbedField>) {
this.data = structuredClone(data) ?? {};
}
/**
* Sets the name for this embed field.
*
* @param name - The name to use
*/
public setName(name: string): this {
this.data.name = name;
return this;
}
/**
* Sets the value for this embed field.
*
* @param value - The value to use
*/
public setValue(value: string): this {
this.data.value = value;
return this;
}
/**
* Sets whether this field should display inline.
*
* @param inline - Whether this field should display inline
*/
public setInline(inline = true): this {
this.data.inline = inline;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIEmbedField {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
embedFieldPredicate.parse(clone);
}
return clone as APIEmbedField;
}
}

View File

@@ -0,0 +1,64 @@
import type { APIEmbedFooter } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { embedFooterPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for the embed footer.
*/
export class EmbedFooterBuilder {
private readonly data: Partial<APIEmbedFooter>;
/**
* Creates a new embed footer from API data.
*
* @param data - The API data to use
*/
public constructor(data?: Partial<APIEmbedFooter>) {
this.data = structuredClone(data) ?? {};
}
/**
* Sets the text for this embed footer.
*
* @param text - The text to use
*/
public setText(text: string): this {
this.data.text = text;
return this;
}
/**
* Sets the url for this embed footer.
*
* @param url - The url to use
*/
public setIconURL(url: string): this {
this.data.icon_url = url;
return this;
}
/**
* Clears the icon URL for this embed footer.
*/
public clearIconURL(): this {
this.data.icon_url = undefined;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIEmbedFooter {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
embedFooterPredicate.parse(clone);
}
return clone as APIEmbedFooter;
}
}