fix: message builders (#10802)

* 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™️

* fix: updater functions return type

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Almeida
2025-04-25 21:52:00 +01:00
committed by GitHub
parent d81b4be2cd
commit 8f375275ca
6 changed files with 165 additions and 70 deletions

View File

@@ -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: [
{

View File

@@ -11,9 +11,9 @@ export class AllowedMentionsBuilder implements JSONEncodable<APIAllowedMentions>
private readonly data: Partial<APIAllowedMentions>;
/**
* 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<APIAllowedMentions> = {}) {
this.data = structuredClone(data);
@@ -29,6 +29,14 @@ export class AllowedMentionsBuilder implements JSONEncodable<APIAllowedMentions>
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<APIAllowedMentions>
* 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<APIAllowedMentions>
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<APIAllowedMentions>
* 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<APIAllowedMentions>
}
/**
* 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;

View File

@@ -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({
export const allowedMentionPredicate = z
.object({
parse: z.nativeEnum(AllowedMentionsTypes).array().optional(),
roles: z.string().array().optional(),
users: z.string().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

View File

@@ -10,15 +10,17 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
private readonly data: Partial<RESTAPIAttachment>;
/**
* 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<RESTAPIAttachment> = {}) {
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<RESTAPIAttachment> {
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<RESTAPIAttachment> {
}
/**
* Sets the waveform of this attachment.
* Sets the waveform of this attachment (audio clips).
*
* @param waveform - The waveform of the attachment
*/

View File

@@ -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<RESTPostAPIChannelMessageJSONBody> {
/**
* The API data associated with this message.
@@ -81,19 +86,27 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
}
/**
* Creates new attachment builder from API data.
* Creates a new message builder from API data.
*
* @param data - The API data to create this attachment with
* @param data - The API data to create this message builder with
*/
public constructor(data: Partial<RESTPostAPIChannelMessageJSONBody> = {}) {
public constructor({
attachments = [],
embeds = [],
components = [],
message_reference,
poll,
allowed_mentions,
...data
}: Partial<RESTPostAPIChannelMessageJSONBody> = {}) {
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<RESTPostAPIChannelMessageJS
/**
* Sets whether the message is TTS.
*
* @param tts - Whether the message is TTS
*/
public setTTS(tts = true): this {
this.data.tts = tts;
@@ -213,6 +228,15 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
return this;
}
/**
* Sets the embeds for this message.
*
* @param embeds - The embeds to set
*/
public setEmbeds(...embeds: RestOrArray<APIEmbed | EmbedBuilder | ((builder: EmbedBuilder) => EmbedBuilder)>): this {
return this.spliceEmbeds(0, this.embeds.length, ...normalizeArray(embeds));
}
/**
* Sets the allowed mentions for this message.
*
@@ -233,8 +257,8 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
*
* @param updater - The function to update the allowed mentions with
*/
public updateAllowedMentions(updater: (builder: AllowedMentionsBuilder) => AllowedMentionsBuilder): this {
this.data.allowed_mentions = updater(this.data.allowed_mentions ?? new AllowedMentionsBuilder());
public updateAllowedMentions(updater: (builder: AllowedMentionsBuilder) => void): this {
updater((this.data.allowed_mentions ??= new AllowedMentionsBuilder()));
return this;
}
@@ -266,8 +290,8 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
*
* @param updater - The function to update the message reference with
*/
public updateMessageReference(updater: (builder: MessageReferenceBuilder) => MessageReferenceBuilder): this {
this.data.message_reference = updater(this.data.message_reference ?? new MessageReferenceBuilder());
public updateMessageReference(updater: (builder: MessageReferenceBuilder) => void): this {
updater((this.data.message_reference ??= new MessageReferenceBuilder()));
return this;
}
@@ -451,8 +475,7 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
* @param stickerIds - The ids of the stickers to set
*/
public setStickerIds(...stickerIds: RestOrArray<Snowflake>): 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<RESTPostAPIChannelMessageJS
public setAttachments(
...attachments: RestOrArray<APIAttachment | AttachmentBuilder | ((builder: AttachmentBuilder) => 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<RESTPostAPIChannelMessageJS
/**
* Sets the flags for this message.
*
* @param flags - The flags to set
*/
public setFlags(flags: MessageFlags): this {
this.data.flags = flags;
@@ -584,7 +606,9 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
}
/**
* Sets `enforce_nonce` for this message.
* Sets whether to enforce recent uniqueness of the nonce of this message.
*
* @param enforceNonce - Whether to enforce recent uniqueness of the nonce of this message
*/
public setEnforceNonce(enforceNonce = true): this {
this.data.enforce_nonce = enforceNonce;
@@ -606,8 +630,8 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
*
* @param updater - The function to update the poll with
*/
public updatePoll(updater: (builder: PollBuilder) => PollBuilder): this {
this.data.poll = updater(this.data.poll ?? new PollBuilder());
public updatePoll(updater: (builder: PollBuilder) => void): this {
updater((this.data.poll ??= new PollBuilder()));
return this;
}
@@ -632,12 +656,12 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
const data = {
...structuredClone(rest),
// Wherever we pass false, it's covered by the messagePredicate already
poll: this.data.poll?.toJSON(false),
poll: poll?.toJSON(false),
allowed_mentions: allowed_mentions?.toJSON(false),
attachments: attachments.map((attachment) => 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),
};

View File

@@ -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<APIMessageReference, 'message_id'> {
message_id: Snowflake;
}
/**
* A builder that creates API-compatible JSON data for message references.
*/
export class MessageReferenceBuilder implements JSONEncodable<MessageReferenceBuilderData> {
private readonly data: Partial<MessageReferenceBuilderData>;
export class MessageReferenceBuilder implements JSONEncodable<RESTAPIMessageReference> {
private readonly data: Partial<RESTAPIMessageReference>;
/**
* 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<MessageReferenceBuilderData> = {}) {
public constructor(data: Partial<RESTAPIMessageReference> = {}) {
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<MessageReferenceBu
return this;
}
/**
* Sets whether to fail the message creation if the referenced message does not exist
*
* @param failIfNotExists - Whether to fail the message creation if the referenced message does not exist
*/
public setFailIfNotExists(failIfNotExists = true): this {
this.data.fail_if_not_exists = failIfNotExists;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
@@ -93,10 +99,10 @@ export class MessageReferenceBuilder implements JSONEncodable<MessageReferenceBu
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): MessageReferenceBuilderData {
public toJSON(validationOverride?: boolean): RESTAPIMessageReference {
const clone = structuredClone(this.data);
validate(messageReferencePredicate, clone, validationOverride);
return clone as MessageReferenceBuilderData;
return clone as RESTAPIMessageReference;
}
}