From 5f72f088a49a234dfe116d287fabed1be4965610 Mon Sep 17 00:00:00 2001 From: Almeida Date: Wed, 23 Apr 2025 10:39:34 +0000 Subject: [PATCH 1/2] fix: message builders - Added `clearParse`, `clearRoles`, and `clearUsers` methods to the `AllowedMentionsBuilder`, since passing an empty array and omitting the these fields behave differently - Strictened assertions - Removed `AttachmentBuilder#clearId`, as it is a required field - Added missing `MessageBuilder#setEmbeds` - Added missing `MessageReferenceBuilder#setFailIfNotExists` - Improve/fix documentation - Consistency:tm: --- .../__tests__/messages/message.test.ts | 34 ++++++++- .../builders/src/messages/AllowedMentions.ts | 36 ++++++++-- packages/builders/src/messages/Assertions.ts | 41 +++++++---- packages/builders/src/messages/Attachment.ts | 18 ++--- packages/builders/src/messages/Message.ts | 70 +++++++++++++------ .../builders/src/messages/MessageReference.ts | 32 +++++---- 6 files changed, 163 insertions(+), 68 deletions(-) diff --git a/packages/builders/__tests__/messages/message.test.ts b/packages/builders/__tests__/messages/message.test.ts index eda225d00b58..c527b134c5bd 100644 --- a/packages/builders/__tests__/messages/message.test.ts +++ b/packages/builders/__tests__/messages/message.test.ts @@ -1,6 +1,6 @@ import { AllowedMentionsTypes, MessageFlags } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { EmbedBuilder, MessageBuilder } from '../../src/index.js'; +import { AllowedMentionsBuilder, EmbedBuilder, MessageBuilder } from '../../src/index.js'; const base = { allowed_mentions: undefined, @@ -24,13 +24,41 @@ describe('Message', () => { expect(() => message.toJSON()).toThrow(); }); + test('GIVEN parse: [users] and empty users THEN return valid toJSON data', () => { + const allowedMentions = new AllowedMentionsBuilder(); + allowedMentions.setUsers(); + allowedMentions.setParse(AllowedMentionsTypes.User); + expect(allowedMentions.toJSON()).toStrictEqual({ parse: [AllowedMentionsTypes.User], users: [] }); + }); + + test('GIVEN parse: [roles] and empty roles THEN return valid toJSON data', () => { + const allowedMentions = new AllowedMentionsBuilder(); + allowedMentions.setRoles(); + allowedMentions.setParse(AllowedMentionsTypes.Role); + expect(allowedMentions.toJSON()).toStrictEqual({ parse: [AllowedMentionsTypes.Role], roles: [] }); + }); + + test('GIVEN specific users and parse: [users] THEN it throws', () => { + const allowedMentions = new AllowedMentionsBuilder(); + allowedMentions.setUsers('123'); + allowedMentions.setParse(AllowedMentionsTypes.User); + expect(() => allowedMentions.toJSON()).toThrow(); + }); + + test('GIVEN specific roles and parse: [roles] THEN it throws', () => { + const allowedMentions = new AllowedMentionsBuilder(); + allowedMentions.setRoles('123'); + allowedMentions.setParse(AllowedMentionsTypes.Role); + expect(() => allowedMentions.toJSON()).toThrow(); + }); + test('GIVEN tons of data THEN return valid toJSON data', () => { const message = new MessageBuilder() .setContent('foo') .setNonce(123) .setTTS() .addEmbeds(new EmbedBuilder().setTitle('foo').setDescription('bar')) - .setAllowedMentions({ parse: [AllowedMentionsTypes.Role], roles: ['123'] }) + .setAllowedMentions({ parse: [AllowedMentionsTypes.Role] }) .setMessageReference({ channel_id: '123', message_id: '123' }) .addActionRowComponents((row) => row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def')), @@ -46,7 +74,7 @@ describe('Message', () => { nonce: 123, tts: true, embeds: [{ title: 'foo', description: 'bar', author: undefined, fields: [], footer: undefined }], - allowed_mentions: { parse: ['roles'], roles: ['123'] }, + allowed_mentions: { parse: ['roles'] }, message_reference: { channel_id: '123', message_id: '123' }, components: [ { diff --git a/packages/builders/src/messages/AllowedMentions.ts b/packages/builders/src/messages/AllowedMentions.ts index 6491b9dbd4e1..a59e4e0f37fb 100644 --- a/packages/builders/src/messages/AllowedMentions.ts +++ b/packages/builders/src/messages/AllowedMentions.ts @@ -11,9 +11,9 @@ export class AllowedMentionsBuilder implements JSONEncodable private readonly data: Partial; /** - * Creates new allowed mention builder from API data. + * Creates a new allowed mentions builder from API data. * - * @param data - The API data to create this attachment with + * @param data - The API data to create this allowed mentions builder with */ public constructor(data: Partial = {}) { this.data = structuredClone(data); @@ -29,6 +29,14 @@ export class AllowedMentionsBuilder implements JSONEncodable return this; } + /** + * Clears the parse mention types. + */ + public clearParse(): this { + this.data.parse = undefined; + return this; + } + /** * Sets the roles to mention. * @@ -65,7 +73,7 @@ export class AllowedMentionsBuilder implements JSONEncodable * allowedMentions.spliceRoles(0, 1); * ``` * @example - * Remove the first n role: + * Remove the first n roles: * ```ts * const n = 4; * allowedMentions.spliceRoles(0, n); @@ -85,6 +93,14 @@ export class AllowedMentionsBuilder implements JSONEncodable return this; } + /** + * Clears the roles to mention. + */ + public clearRoles(): this { + this.data.roles = undefined; + return this; + } + /** * Sets the users to mention. * @@ -120,7 +136,7 @@ export class AllowedMentionsBuilder implements JSONEncodable * allowedMentions.spliceUsers(0, 1); * ``` * @example - * Remove the first n user: + * Remove the first n users: * ```ts * const n = 4; * allowedMentions.spliceUsers(0, n); @@ -141,7 +157,17 @@ export class AllowedMentionsBuilder implements JSONEncodable } /** - * For replies, sets whether to mention the author of the message being replied to + * Clears the users to mention. + */ + public clearUsers(): this { + this.data.users = undefined; + return this; + } + + /** + * For replies, sets whether to mention the author of the message being replied to. + * + * @param repliedUser - Whether to mention the author of the message being replied to */ public setRepliedUser(repliedUser = true): this { this.data.replied_user = repliedUser; diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts index a26c20a96d6f..da2cb7911295 100644 --- a/packages/builders/src/messages/Assertions.ts +++ b/packages/builders/src/messages/Assertions.ts @@ -5,19 +5,34 @@ import { pollPredicate } from './poll/Assertions.js'; export const attachmentPredicate = z.object({ id: z.union([z.string(), z.number()]), - description: z.string().optional(), - duration_secs: z.number().optional(), - filename: z.string().optional(), - title: z.string().optional(), - waveform: z.string().optional(), + description: z.string().max(1_024).optional(), + duration_secs: z + .number() + .max(2 ** 31 - 1) + .optional(), + filename: z.string().max(1_024).optional(), + title: z.string().max(1_024).optional(), + waveform: z.string().max(400).optional(), }); -export const allowedMentionPredicate = z.object({ - parse: z.nativeEnum(AllowedMentionsTypes).array().optional(), - roles: z.string().array().optional(), - users: z.string().array().optional(), - replied_user: z.boolean().optional(), -}); +export const allowedMentionPredicate = z + .object({ + parse: z.nativeEnum(AllowedMentionsTypes).array().optional(), + roles: z.string().array().max(100).optional(), + users: z.string().array().max(100).optional(), + replied_user: z.boolean().optional(), + }) + .refine( + (data) => + !( + (data.parse?.includes(AllowedMentionsTypes.User) && data.users?.length) || + (data.parse?.includes(AllowedMentionsTypes.Role) && data.roles?.length) + ), + { + message: + 'Cannot specify both parse: ["users"] and non-empty users array, or parse: ["roles"] and non-empty roles array. These are mutually exclusive', + }, + ); export const messageReferencePredicate = z.object({ channel_id: z.string().optional(), @@ -54,9 +69,9 @@ const basicActionRowPredicate = z.object({ const messageNoComponentsV2Predicate = baseMessagePredicate .extend({ - content: z.string().optional(), + content: z.string().max(2_000).optional(), embeds: embedPredicate.array().max(10).optional(), - sticker_ids: z.array(z.string()).min(0).max(3).optional(), + sticker_ids: z.array(z.string()).max(3).optional(), poll: pollPredicate.optional(), components: basicActionRowPredicate.array().max(5).optional(), flags: z diff --git a/packages/builders/src/messages/Attachment.ts b/packages/builders/src/messages/Attachment.ts index b078d21abb7d..06ee67992fa4 100644 --- a/packages/builders/src/messages/Attachment.ts +++ b/packages/builders/src/messages/Attachment.ts @@ -10,15 +10,17 @@ export class AttachmentBuilder implements JSONEncodable { private readonly data: Partial; /** - * Creates new attachment builder from API data. + * Creates a new attachment builder from API data. * - * @param data - The API data to create this attachment with + * @param data - The API data to create this attachment builder with */ public constructor(data: Partial = {}) { this.data = structuredClone(data); } /** + * Sets the id of the attachment. + * * @param id - The id of the attachment */ public setId(id: Snowflake): this { @@ -26,16 +28,10 @@ export class AttachmentBuilder implements JSONEncodable { return this; } - /** - * Clears the id of this attachment. - */ - public clearId(): this { - this.data.id = undefined; - return this; - } - /** * Sets the description of this attachment. + * + * @param description - The description of the attachment */ public setDescription(description: string): this { this.data.description = description; @@ -105,7 +101,7 @@ export class AttachmentBuilder implements JSONEncodable { } /** - * Sets the waveform of this attachment. + * Sets the waveform of this attachment (audio clips). * * @param waveform - The waveform of the attachment */ diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index 304bd7eccd31..8c3337d1d328 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -1,3 +1,5 @@ +/* eslint-disable jsdoc/check-param-names */ + import type { JSONEncodable } from '@discordjs/util'; import type { APIActionRowComponent, @@ -53,6 +55,9 @@ export interface MessageBuilderData poll?: PollBuilder; } +/** + * A builder that creates API-compatible JSON data for messages. + */ export class MessageBuilder implements JSONEncodable { /** * The API data associated with this message. @@ -81,19 +86,27 @@ export class MessageBuilder implements JSONEncodable = {}) { + * @param data - The API data to create this message builder with + */ + public constructor({ + attachments = [], + embeds = [], + components = [], + message_reference, + poll, + allowed_mentions, + ...data + }: Partial = {}) { this.data = { ...structuredClone(data), - allowed_mentions: data.allowed_mentions ? new AllowedMentionsBuilder(data.allowed_mentions) : undefined, - 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) => createComponentBuilder(component)) ?? [], - message_reference: data.message_reference ? new MessageReferenceBuilder(data.message_reference) : undefined, + allowed_mentions: allowed_mentions && new AllowedMentionsBuilder(allowed_mentions), + attachments: attachments.map((attachment) => new AttachmentBuilder(attachment)), + embeds: embeds.map((embed) => new EmbedBuilder(embed)), + poll: poll && new PollBuilder(poll), + components: components.map((component) => createComponentBuilder(component)), + message_reference: message_reference && new MessageReferenceBuilder(message_reference), }; } @@ -135,6 +148,8 @@ export class MessageBuilder implements JSONEncodable EmbedBuilder)>): this { + return this.spliceEmbeds(0, this.embeds.length, ...normalizeArray(embeds)); + } + /** * Sets the allowed mentions for this message. * @@ -234,7 +258,7 @@ export class MessageBuilder implements JSONEncodable AllowedMentionsBuilder): this { - this.data.allowed_mentions = updater(this.data.allowed_mentions ?? new AllowedMentionsBuilder()); + updater((this.data.allowed_mentions ??= new AllowedMentionsBuilder())); return this; } @@ -267,7 +291,7 @@ export class MessageBuilder implements JSONEncodable MessageReferenceBuilder): this { - this.data.message_reference = updater(this.data.message_reference ?? new MessageReferenceBuilder()); + updater((this.data.message_reference ??= new MessageReferenceBuilder())); return this; } @@ -451,8 +475,7 @@ export class MessageBuilder implements JSONEncodable): this { - this.data.sticker_ids = normalizeArray(stickerIds) as MessageBuilderData['sticker_ids']; - return this; + return this.spliceStickerIds(0, this.data.sticker_ids?.length ?? 0, ...normalizeArray(stickerIds)); } /** @@ -508,10 +531,7 @@ export class MessageBuilder implements JSONEncodable AttachmentBuilder)> ): this { - const resolved = normalizeArray(attachments).map((attachment) => resolveBuilder(attachment, AttachmentBuilder)); - this.data.attachments = resolved; - - return this; + return this.spliceAttachments(0, this.data.attachments.length, ...normalizeArray(attachments)); } /** @@ -569,6 +589,8 @@ export class MessageBuilder implements JSONEncodable PollBuilder): this { - this.data.poll = updater(this.data.poll ?? new PollBuilder()); + updater((this.data.poll ??= new PollBuilder())); return this; } @@ -632,12 +656,12 @@ export class MessageBuilder implements JSONEncodable attachment.toJSON(false)), - embeds: this.data.embeds.map((embed) => embed.toJSON(false)), + embeds: embeds.map((embed) => embed.toJSON(false)), // Here, the messagePredicate does specific constraints rather than using the componentPredicate - components: this.data.components?.map((component) => component.toJSON(validationOverride)), + components: components.map((component) => component.toJSON(validationOverride)), message_reference: message_reference?.toJSON(false), }; diff --git a/packages/builders/src/messages/MessageReference.ts b/packages/builders/src/messages/MessageReference.ts index 84c499362e4c..d915ab0891a6 100644 --- a/packages/builders/src/messages/MessageReference.ts +++ b/packages/builders/src/messages/MessageReference.ts @@ -1,29 +1,25 @@ import type { JSONEncodable } from '@discordjs/util'; -import type { APIMessageReference, MessageReferenceType, Snowflake } from 'discord-api-types/v10'; +import type { MessageReferenceType, RESTAPIMessageReference, Snowflake } from 'discord-api-types/v10'; import { validate } from '../util/validation.js'; import { messageReferencePredicate } from './Assertions.js'; -export interface MessageReferenceBuilderData extends Omit { - message_id: Snowflake; -} - /** * A builder that creates API-compatible JSON data for message references. */ -export class MessageReferenceBuilder implements JSONEncodable { - private readonly data: Partial; +export class MessageReferenceBuilder implements JSONEncodable { + private readonly data: Partial; /** - * Creates new allowed mention builder from API data. + * Creates a new message reference builder from API data. * - * @param data - The API data to create this attachment with + * @param data - The API data to create this message reference builder with */ - public constructor(data: Partial = {}) { + public constructor(data: Partial = {}) { this.data = structuredClone(data); } /** - * Sets the types of message reference this represents + * Sets the type of message reference this represents * * @param type - The type of message reference */ @@ -86,6 +82,16 @@ export class MessageReferenceBuilder implements JSONEncodable Date: Wed, 23 Apr 2025 11:31:15 +0000 Subject: [PATCH 2/2] fix: updater functions return type --- packages/builders/src/messages/Message.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index 8c3337d1d328..b74d6d78825f 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -257,7 +257,7 @@ export class MessageBuilder implements JSONEncodable AllowedMentionsBuilder): this { + public updateAllowedMentions(updater: (builder: AllowedMentionsBuilder) => void): this { updater((this.data.allowed_mentions ??= new AllowedMentionsBuilder())); return this; } @@ -290,7 +290,7 @@ export class MessageBuilder implements JSONEncodable MessageReferenceBuilder): this { + public updateMessageReference(updater: (builder: MessageReferenceBuilder) => void): this { updater((this.data.message_reference ??= new MessageReferenceBuilder())); return this; } @@ -630,7 +630,7 @@ export class MessageBuilder implements JSONEncodable PollBuilder): this { + public updatePoll(updater: (builder: PollBuilder) => void): this { updater((this.data.poll ??= new PollBuilder())); return this; }