feat: components v2 in builders (#10788)

* feat: thumbnail component

* chore: just a temp file to track remaining components

* feat: file component

* feat: section component

* feat: text display component

* chore: bump alpha version of dtypes

* chore: simplify ComponentBuilder base type

* feat: MediaGallery

* feat: Section builder

* chore: tests for sections

* chore: forgot you

* chore: docs

* fix: missing comma

* fix: my bad

* feat: container builder

* chore: requested changes

* chore: missed u

* chore: type tests

* chore: setId/clearId

* chore: apply suggestions from code review

* chore: unify pick

* chore: some requested changes

* chore: tests and small fixes

* chore: added tests that need fixing

* fix: tests

* chore: cleanup on isle protected

* docs: remove locale

* chore: types for new message builder

* chore: fix tests

* chore: attempt 1 at message builder assertions

* chore: apply suggestions

* Update packages/builders/src/messages/Assertions.ts

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* Update packages/builders/src/components/v2/Thumbnail.ts

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* fix: tests

* chore: fmt

* Apply suggestions from code review

Co-authored-by: Denis-Adrian Cristea <didinele.dev@gmail.com>

* chore: fix pnpm lockfile revert

---------

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Denis-Adrian Cristea <didinele.dev@gmail.com>
This commit is contained in:
Vlad Frangu
2025-04-23 20:29:15 +03:00
committed by GitHub
parent 42ce116226
commit abc5d99ce8
31 changed files with 2176 additions and 116 deletions

View File

@@ -1,4 +1,4 @@
import { AllowedMentionsTypes, ComponentType, MessageReferenceType } from 'discord-api-types/v10';
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
import { z } from 'zod';
import { embedPredicate } from './embed/Assertions.js';
import { pollPredicate } from './poll/Assertions.js';
@@ -27,40 +27,49 @@ export const messageReferencePredicate = z.object({
type: z.nativeEnum(MessageReferenceType).optional(),
});
export const messagePredicate = z
.object({
const baseMessagePredicate = z.object({
nonce: z.union([z.string().max(25), z.number()]).optional(),
tts: z.boolean().optional(),
allowed_mentions: allowedMentionPredicate.optional(),
message_reference: messageReferencePredicate.optional(),
attachments: attachmentPredicate.array().max(10).optional(),
enforce_nonce: z.boolean().optional(),
});
const basicActionRowPredicate = z.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({
type: z.union([
z.literal(ComponentType.Button),
z.literal(ComponentType.ChannelSelect),
z.literal(ComponentType.MentionableSelect),
z.literal(ComponentType.RoleSelect),
z.literal(ComponentType.StringSelect),
z.literal(ComponentType.UserSelect),
]),
})
.array(),
});
const messageNoComponentsV2Predicate = baseMessagePredicate
.extend({
content: z.string().optional(),
nonce: z.union([z.string().max(25), z.number()]).optional(),
tts: z.boolean().optional(),
embeds: embedPredicate.array().max(10).optional(),
allowed_mentions: allowedMentionPredicate.optional(),
message_reference: messageReferencePredicate.optional(),
// Partial validation here to ensure the components are valid,
// rest of the validation is done in the action row predicate
components: z
.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({
type: z.union([
z.literal(ComponentType.Button),
z.literal(ComponentType.ChannelSelect),
z.literal(ComponentType.MentionableSelect),
z.literal(ComponentType.RoleSelect),
z.literal(ComponentType.StringSelect),
z.literal(ComponentType.UserSelect),
]),
})
.array(),
})
.array()
.max(5)
.optional(),
sticker_ids: z.array(z.string()).min(0).max(3).optional(),
attachments: attachmentPredicate.array().max(10).optional(),
flags: z.number().optional(),
enforce_nonce: z.boolean().optional(),
poll: pollPredicate.optional(),
components: basicActionRowPredicate.array().max(5).optional(),
flags: z
.number()
.optional()
.refine((flags) => {
// If we have flags, ensure we don't have the ComponentsV2 flag
if (flags) {
return (flags & MessageFlags.IsComponentsV2) === 0;
}
return true;
}),
})
.refine(
(data) =>
@@ -70,5 +79,37 @@ export const messagePredicate = z
(data.attachments !== undefined && data.attachments.length > 0) ||
(data.components !== undefined && data.components.length > 0) ||
(data.sticker_ids !== undefined && data.sticker_ids.length > 0),
{ message: 'Messages must have content, embeds, a poll, attachments, components, or stickers' },
{ message: 'Messages must have content, embeds, a poll, attachments, components or stickers' },
);
const allTopLevelComponentsPredicate = z
.union([
basicActionRowPredicate,
z.object({
type: z.union([
// Components v2
z.literal(ComponentType.Container),
z.literal(ComponentType.File),
z.literal(ComponentType.MediaGallery),
z.literal(ComponentType.Section),
z.literal(ComponentType.Separator),
z.literal(ComponentType.TextDisplay),
z.literal(ComponentType.Thumbnail),
]),
}),
])
.array()
.min(1)
.max(10);
const messageComponentsV2Predicate = baseMessagePredicate.extend({
components: allTopLevelComponentsPredicate,
flags: z.number().refine((flags) => (flags & MessageFlags.IsComponentsV2) === MessageFlags.IsComponentsV2),
// These fields cannot be set
content: z.string().length(0).nullish(),
embeds: z.array(z.never()).nullish(),
sticker_ids: z.array(z.never()).nullish(),
poll: z.null().optional(),
});
export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]);

View File

@@ -10,9 +10,24 @@ import type {
RESTPostAPIChannelMessageJSONBody,
Snowflake,
MessageFlags,
APIComponentInActionRow,
APIContainerComponent,
APIFileComponent,
APIMediaGalleryComponent,
APISectionComponent,
APISeparatorComponent,
APITextDisplayComponent,
APIMessageTopLevelComponent,
} from 'discord-api-types/v10';
import { ActionRowBuilder } from '../components/ActionRow.js';
import { ComponentBuilder } from '../components/Component.js';
import type { MessageTopLevelComponentBuilder } from '../components/Components.js';
import { createComponentBuilder } from '../components/Components.js';
import { ContainerBuilder } from '../components/v2/Container.js';
import { FileBuilder } from '../components/v2/File.js';
import { MediaGalleryBuilder } from '../components/v2/MediaGallery.js';
import { SectionBuilder } from '../components/v2/Section.js';
import { SeparatorBuilder } from '../components/v2/Separator.js';
import { TextDisplayBuilder } from '../components/v2/TextDisplay.js';
import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { resolveBuilder } from '../util/resolveBuilder.js';
import { validate } from '../util/validation.js';
@@ -32,7 +47,7 @@ export interface MessageBuilderData
> {
allowed_mentions?: AllowedMentionsBuilder;
attachments: AttachmentBuilder[];
components: ActionRowBuilder[];
components: MessageTopLevelComponentBuilder[];
embeds: EmbedBuilder[];
message_reference?: MessageReferenceBuilder;
poll?: PollBuilder;
@@ -54,7 +69,7 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
/**
* Gets the components of this message.
*/
public get components(): readonly ActionRowBuilder[] {
public get components(): readonly MessageTopLevelComponentBuilder[] {
return this.data.components;
}
@@ -77,10 +92,7 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
attachments: data.attachments?.map((attachment) => new AttachmentBuilder(attachment)) ?? [],
embeds: data.embeds?.map((embed) => new EmbedBuilder(embed)) ?? [],
poll: data.poll ? new PollBuilder(data.poll) : undefined,
components:
data.components?.map(
(component) => new ActionRowBuilder(component as unknown as APIActionRowComponent<APIComponentInActionRow>),
) ?? [],
components: data.components?.map((component) => createComponentBuilder(component)) ?? [],
message_reference: data.message_reference ? new MessageReferenceBuilder(data.message_reference) : undefined,
};
}
@@ -268,11 +280,11 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
}
/**
* Adds components to this message.
* Adds action row components to this message.
*
* @param components - The components to add
* @param components - The action row components to add
*/
public addComponents(
public addActionRowComponents(
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIComponentInMessageActionRow>
@@ -287,6 +299,110 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
return this;
}
/**
* Adds container components to this message.
*
* @param components - The container components to add
*/
public addContainerComponents(
...components: RestOrArray<
APIContainerComponent | ContainerBuilder | ((builder: ContainerBuilder) => ContainerBuilder)
>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, ContainerBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds file components to this message.
*
* @param components - The file components to add
*/
public addFileComponents(
...components: RestOrArray<APIFileComponent | FileBuilder | ((builder: FileBuilder) => FileBuilder)>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, FileBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds media gallery components to this message.
*
* @param components - The media gallery components to add
*/
public addMediaGalleryComponents(
...components: RestOrArray<
APIMediaGalleryComponent | MediaGalleryBuilder | ((builder: MediaGalleryBuilder) => MediaGalleryBuilder)
>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, MediaGalleryBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds section components to this message.
*
* @param components - The section components to add
*/
public addSectionComponents(
...components: RestOrArray<APISectionComponent | SectionBuilder | ((builder: SectionBuilder) => SectionBuilder)>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, SectionBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds separator components to this message.
*
* @param components - The separator components to add
*/
public addSeparatorComponents(
...components: RestOrArray<
APISeparatorComponent | SeparatorBuilder | ((builder: SeparatorBuilder) => SeparatorBuilder)
>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, SeparatorBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds text display components to this message.
*
* @param components - The text display components to add
*/
public addTextDisplayComponents(
...components: RestOrArray<
APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)
>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, TextDisplayBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Removes, replaces, or inserts components for this message.
*
@@ -318,35 +434,17 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
public spliceComponents(
start: number,
deleteCount: number,
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIComponentInMessageActionRow>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
>
...components: RestOrArray<APIMessageTopLevelComponent | MessageTopLevelComponentBuilder>
): this {
this.data.components ??= [];
const resolved = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder));
const resolved = normalizeArray(components).map((component) =>
component instanceof ComponentBuilder ? component : createComponentBuilder(component),
);
this.data.components.splice(start, deleteCount, ...resolved);
return this;
}
/**
* Sets the components of this message.
*
* @param components - The components to set
*/
public setComponents(
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIComponentInMessageActionRow>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
>
): this {
this.data.components = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder));
return this;
}
/**
* Sets the sticker ids of this message.
*

View File

@@ -1,3 +1,4 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIEmbedAuthor } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import { embedAuthorPredicate } from './Assertions.js';
@@ -5,7 +6,7 @@ import { embedAuthorPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for the embed author.
*/
export class EmbedAuthorBuilder {
export class EmbedAuthorBuilder implements JSONEncodable<APIEmbedAuthor> {
private readonly data: Partial<APIEmbedAuthor>;
/**

View File

@@ -1,3 +1,4 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIEmbedField } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import { embedFieldPredicate } from './Assertions.js';
@@ -5,7 +6,7 @@ import { embedFieldPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for embed fields.
*/
export class EmbedFieldBuilder {
export class EmbedFieldBuilder implements JSONEncodable<APIEmbedField> {
private readonly data: Partial<APIEmbedField>;
/**

View File

@@ -1,3 +1,4 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIEmbedFooter } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import { embedFooterPredicate } from './Assertions.js';
@@ -5,7 +6,7 @@ import { embedFooterPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for the embed footer.
*/
export class EmbedFooterBuilder {
export class EmbedFooterBuilder implements JSONEncodable<APIEmbedFooter> {
private readonly data: Partial<APIEmbedFooter>;
/**

View File

@@ -1,3 +1,4 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIPollAnswer, APIPollMedia } from 'discord-api-types/v10';
import { resolveBuilder } from '../../util/resolveBuilder';
import { validate } from '../../util/validation';
@@ -8,11 +9,11 @@ export interface PollAnswerData extends Omit<APIPollAnswer, 'answer_id' | 'poll_
poll_media: PollAnswerMediaBuilder;
}
export class PollAnswerBuilder {
export class PollAnswerBuilder implements JSONEncodable<Omit<APIPollAnswer, 'answer_id'>> {
/**
* The API data associated with this poll answer.
*/
protected readonly data: PollAnswerData;
private readonly data: PollAnswerData;
/**
* Creates a new poll answer from API data.