From 53d0b0385b09cf5df77e5af0417cc975b1f867e5 Mon Sep 17 00:00:00 2001 From: didinele Date: Thu, 27 Feb 2025 21:34:16 +0200 Subject: [PATCH 1/8] feat: attachment builder --- packages/builders/src/messages/Assertions.ts | 10 ++ packages/builders/src/messages/Attachment.ts | 138 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/builders/src/messages/Assertions.ts create mode 100644 packages/builders/src/messages/Attachment.ts diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts new file mode 100644 index 000000000000..c943fc86cfc0 --- /dev/null +++ b/packages/builders/src/messages/Assertions.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +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(), +}); diff --git a/packages/builders/src/messages/Attachment.ts b/packages/builders/src/messages/Attachment.ts new file mode 100644 index 000000000000..b078d21abb7d --- /dev/null +++ b/packages/builders/src/messages/Attachment.ts @@ -0,0 +1,138 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { RESTAPIAttachment, Snowflake } from 'discord-api-types/v10'; +import { validate } from '../util/validation.js'; +import { attachmentPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for attachments. + */ +export class AttachmentBuilder implements JSONEncodable { + private readonly data: Partial; + + /** + * Creates new attachment builder from API data. + * + * @param data - The API data to create this attachment with + */ + public constructor(data: Partial = {}) { + this.data = structuredClone(data); + } + + /** + * @param id - The id of the attachment + */ + public setId(id: Snowflake): this { + this.data.id = id; + return this; + } + + /** + * Clears the id of this attachment. + */ + public clearId(): this { + this.data.id = undefined; + return this; + } + + /** + * Sets the description of this attachment. + */ + public setDescription(description: string): this { + this.data.description = description; + return this; + } + + /** + * Clears the description of this attachment. + */ + public clearDescription(): this { + this.data.description = undefined; + return this; + } + + /** + * Sets the duration of this attachment (audio clips). + * + * @param duration - The duration of the attachment in seconds + */ + public setDuration(duration: number): this { + this.data.duration_secs = duration; + return this; + } + + /** + * Clears the duration of this attachment. + */ + public clearDuration(): this { + this.data.duration_secs = undefined; + return this; + } + + /** + * Sets the filename of this attachment. + * + * @param filename - The filename of the attachment + */ + public setFilename(filename: string): this { + this.data.filename = filename; + return this; + } + + /** + * Clears the filename of this attachment. + */ + public clearFilename(): this { + this.data.filename = undefined; + return this; + } + + /** + * Sets the title of this attachment. + * + * @param title - The title of the attachment + */ + public setTitle(title: string): this { + this.data.title = title; + return this; + } + + /** + * Clears the title of this attachment. + */ + public clearTitle(): this { + this.data.title = undefined; + return this; + } + + /** + * Sets the waveform of this attachment. + * + * @param waveform - The waveform of the attachment + */ + public setWaveform(waveform: string): this { + this.data.waveform = waveform; + return this; + } + + /** + * Clears the waveform of this attachment. + */ + public clearWaveform(): this { + this.data.waveform = 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): RESTAPIAttachment { + const clone = structuredClone(this.data); + validate(attachmentPredicate, clone, validationOverride); + + return clone as RESTAPIAttachment; + } +} From 408ca333204246240bfb4d02d5e626c6d644d1de Mon Sep 17 00:00:00 2001 From: didinele Date: Fri, 28 Feb 2025 10:31:03 +0200 Subject: [PATCH 2/8] feat: message builder --- .../__tests__/messages/message.test.ts | 65 +++ packages/builders/src/index.ts | 6 + .../builders/src/messages/AllowedMentions.ts | 172 ++++++ packages/builders/src/messages/Assertions.ts | 67 +++ packages/builders/src/messages/Message.ts | 544 ++++++++++++++++++ .../builders/src/messages/MessageReference.ts | 98 ++++ 6 files changed, 952 insertions(+) create mode 100644 packages/builders/__tests__/messages/message.test.ts create mode 100644 packages/builders/src/messages/AllowedMentions.ts create mode 100644 packages/builders/src/messages/Message.ts create mode 100644 packages/builders/src/messages/MessageReference.ts diff --git a/packages/builders/__tests__/messages/message.test.ts b/packages/builders/__tests__/messages/message.test.ts new file mode 100644 index 000000000000..48a064073df0 --- /dev/null +++ b/packages/builders/__tests__/messages/message.test.ts @@ -0,0 +1,65 @@ +import { AllowedMentionsTypes, MessageFlags } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { EmbedBuilder, MessageBuilder } from '../../src/index.js'; + +const base = { + allowed_mentions: undefined, + attachments: [], + components: [], + embeds: [], + message_reference: undefined, + poll: undefined, +}; + +describe('Message', () => { + test('GIVEN a message with pre-defined content THEN return valid toJSON data', () => { + const message = new MessageBuilder({ content: 'foo' }); + expect(message.toJSON()).toStrictEqual({ ...base, content: 'foo' }); + }); + + test('GIVEN bad action row THEN it throws', () => { + const message = new MessageBuilder().setComponents((row) => + row.addTextInputComponent((input) => input.setCustomId('abc').setLabel('def')), + ); + expect(() => message.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'] }) + .setMessageReference({ channel_id: '123', message_id: '123' }) + .setComponents((row) => row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def'))) + .setStickerIds('123', '456') + .addAttachments((attachment) => attachment.setId('hi!').setFilename('abc')) + .setFlags(MessageFlags.Ephemeral) + .setEnforceNonce(false) + .updatePoll((poll) => poll.addAnswers({ poll_media: { text: 'foo' } }).setQuestion({ text: 'foo' })); + + expect(message.toJSON()).toStrictEqual({ + content: 'foo', + nonce: 123, + tts: true, + embeds: [{ title: 'foo', description: 'bar', author: undefined, fields: [], footer: undefined }], + allowed_mentions: { parse: ['roles'], roles: ['123'] }, + message_reference: { channel_id: '123', message_id: '123' }, + components: [ + { + type: 1, + components: [{ type: 2, custom_id: 'abc', label: 'def', style: 1 }], + }, + ], + sticker_ids: ['123', '456'], + attachments: [{ id: 'hi!', filename: 'abc' }], + flags: 64, + enforce_nonce: false, + poll: { + question: { text: 'foo' }, + answers: [{ poll_media: { text: 'foo' } }], + }, + }); + }); +}); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 460c4c2d19f0..1e6c779b52f5 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -67,6 +67,12 @@ export * from './messages/poll/PollAnswerMedia.js'; export * from './messages/poll/PollMedia.js'; export * from './messages/poll/PollQuestion.js'; +export * from './messages/AllowedMentions.js'; +export * from './messages/Assertions.js'; +export * from './messages/Attachment.js'; +export * from './messages/Message.js'; +export * from './messages/MessageReference.js'; + export * from './util/componentUtil.js'; export * from './util/normalizeArray.js'; export * from './util/resolveBuilder.js'; diff --git a/packages/builders/src/messages/AllowedMentions.ts b/packages/builders/src/messages/AllowedMentions.ts new file mode 100644 index 000000000000..3f126a3b6d81 --- /dev/null +++ b/packages/builders/src/messages/AllowedMentions.ts @@ -0,0 +1,172 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { AllowedMentionsTypes, APIAllowedMentions, Snowflake } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; +import { validate } from '../util/validation.js'; +import { allowedMentionPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for allowed mentions. + */ +export class AllowedMentionsBuilder implements JSONEncodable { + private readonly data: Partial; + + /** + * Creates new allowed mention builder from API data. + * + * @param data - The API data to create this attachment with + */ + public constructor(data: Partial = {}) { + this.data = structuredClone(data); + } + + /** + * Sets the types of mentions to parse from the content. + * + * @param parse - The types of mentions to parse from the content + */ + public setParse(...parse: RestOrArray): this { + this.data.parse = normalizeArray(parse); + return this; + } + + /** + * Clear the types of mentions to parse from the content. + */ + public clearParse(): this { + this.data.parse = []; + return this; + } + + /** + * Sets the roles to mention. + * + * @param roles - The roles to mention + */ + public setRoles(...roles: RestOrArray): this { + this.data.roles = normalizeArray(roles); + return this; + } + + /** + * Adds roles to mention. + * + * @param roles - The roles to mention + */ + public addRoles(...roles: RestOrArray): this { + this.data.roles ??= []; + this.data.roles.push(...normalizeArray(roles)); + + return this; + } + + /** + * Removes, replaces, or inserts roles. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing roles. + * @example + * Remove the first role: + * ```ts + * allowedMentions.spliceRoles(0, 1); + * ``` + * @example + * Remove the first n role: + * ```ts + * const n = 4; + * allowedMentions.spliceRoles(0, n); + * ``` + * @example + * Remove the last role: + * ```ts + * allowedMentions.spliceRoles(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of roles to remove + * @param roles - The replacing role IDs + */ + public spliceRoles(index: number, deleteCount: number, ...roles: RestOrArray): this { + this.data.roles ??= []; + this.data.roles.splice(index, deleteCount, ...normalizeArray(roles)); + return this; + } + + /** + * Sets the users to mention. + * + * @param users - The users to mention + */ + public setUsers(...users: RestOrArray): this { + this.data.users = normalizeArray(users); + return this; + } + + /** + * Adds users to mention. + * + * @param users - The users to mention + */ + public addUsers(...users: RestOrArray): this { + this.data.users ??= []; + this.data.users.push(...normalizeArray(users)); + return this; + } + + /** + * Removes, replaces, or inserts users. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing users. + * @example + * Remove the first user: + * ```ts + * allowedMentions.spliceUsers(0, 1); + * ``` + * @example + * Remove the first n user: + * ```ts + * const n = 4; + * allowedMentions.spliceUsers(0, n); + * ``` + * @example + * Remove the last user: + * ```ts + * allowedMentions.spliceUsers(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of users to remove + * @param users - The replacing user IDs + */ + public spliceUsers(index: number, deleteCount: number, ...users: RestOrArray): this { + this.data.users ??= []; + this.data.users.splice(index, deleteCount, ...normalizeArray(users)); + return this; + } + + /** + * For replies, sets whether to mention the author of the message being replied to + */ + public setRepliedUser(repliedUser = true): this { + this.data.replied_user = repliedUser; + 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): APIAllowedMentions { + const clone = structuredClone(this.data); + validate(allowedMentionPredicate, clone, validationOverride); + + return clone as APIAllowedMentions; + } +} diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts index c943fc86cfc0..4cd473572dd8 100644 --- a/packages/builders/src/messages/Assertions.ts +++ b/packages/builders/src/messages/Assertions.ts @@ -1,4 +1,7 @@ +import { AllowedMentionsTypes, ComponentType, MessageReferenceType } from 'discord-api-types/v10'; import { z } from 'zod'; +import { embedPredicate } from './embed/Assertions.js'; +import { pollPredicate } from './poll/Assertions.js'; export const attachmentPredicate = z.object({ id: z.union([z.string(), z.number()]), @@ -8,3 +11,67 @@ export const attachmentPredicate = z.object({ title: z.string().optional(), waveform: z.string().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 messageReferencePredicate = z.object({ + channel_id: z.string().optional(), + fail_if_not_exists: z.boolean().optional(), + guild_id: z.string().optional(), + message_id: z.string(), + type: z.nativeEnum(MessageReferenceType).optional(), +}); + +export const messagePredicate = z + .object({ + content: z.string().optional(), + nonce: z.union([z.string(), 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(), + }) + .refine( + (data) => { + return ( + data.content !== undefined || + (data.embeds !== undefined && data.embeds.length > 0) || + data.poll !== undefined || + (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 just have at least one field set' }, + ); diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts new file mode 100644 index 000000000000..ec12b501dc21 --- /dev/null +++ b/packages/builders/src/messages/Message.ts @@ -0,0 +1,544 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { + APIActionRowComponent, + APIAllowedMentions, + APIAttachment, + APIEmbed, + APIMessageActionRowComponent, + APIMessageReference, + APIPoll, + RESTPostAPIChannelMessageJSONBody, + Snowflake, +} from 'discord-api-types/v10'; +import { ActionRowBuilder } from '../components/ActionRow.js'; +import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; +import { resolveBuilder } from '../util/resolveBuilder.js'; +import { validate } from '../util/validation.js'; +import { AllowedMentionsBuilder } from './AllowedMentions.js'; +import { messagePredicate } from './Assertions.js'; +import { AttachmentBuilder } from './Attachment.js'; +import { MessageReferenceBuilder } from './MessageReference.js'; +import { EmbedBuilder } from './embed/Embed.js'; +import { PollBuilder } from './poll/Poll.js'; + +export interface MessageBuilderData + extends Partial< + Omit< + RESTPostAPIChannelMessageJSONBody, + 'allowed_mentions' | 'attachments' | 'components' | 'embeds' | 'message_reference' | 'poll' + > + > { + allowed_mentions?: AllowedMentionsBuilder; + attachments: AttachmentBuilder[]; + components?: ActionRowBuilder[]; + embeds: EmbedBuilder[]; + message_reference?: MessageReferenceBuilder; + poll?: PollBuilder; +} + +export class MessageBuilder implements JSONEncodable { + /** + * The API data associated with this message. + */ + private readonly data: MessageBuilderData; + + /** + * Gets the attachments of this message. + */ + public get attachments(): readonly AttachmentBuilder[] { + return this.data.attachments; + } + + /** + * Gets the components of this message. + */ + public get components(): readonly ActionRowBuilder[] { + return this.data.components ?? []; + } + + /** + * Gets the embeds of this message. + */ + public get embeds(): readonly EmbedBuilder[] { + return this.data.embeds; + } + + /** + * Creates new attachment builder from API data. + * + * @param data - The API data to create this attachment with + */ + public constructor(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) => new ActionRowBuilder(component)) ?? [], + message_reference: data.message_reference ? new MessageReferenceBuilder(data.message_reference) : undefined, + }; + } + + /** + * Sets the content of the message. + * + * @param content - The content to set + */ + public setContent(content: string): this { + this.data.content = content; + return this; + } + + /** + * Clears the content of the message. + */ + public clearContent(): this { + this.data.content = undefined; + return this; + } + + /** + * Sets the nonce of the message. + * + * @param nonce - The nonce to set + */ + public setNonce(nonce: number | string): this { + this.data.nonce = nonce; + return this; + } + + /** + * Clears the nonce of the message. + */ + public clearNonce(): this { + this.data.nonce = undefined; + return this; + } + + /** + * Sets weather the message is TTS. + */ + public setTTS(tts = true): this { + this.data.tts = tts; + return this; + } + + /** + * Appends embeds to this message. + * + * @remarks + * The maximum amount of embeds that can be added is 10. + * @example + * Using an array: + * ```ts + * const embeds: APIEmbed[] = ...; + * const messsage = new MessageBuilder() + * .addEmbeds(embeds); + * ``` + * @example + * Using rest parameters (variadic): + * ```ts + * const message = new MessageBuilder() + * .addEmbeds( + * { title: 'Embed 1' }, + * { title: 'Embed 2' }, + * ); + * ``` + * @param embeds - The embeds to add + */ + public addEmbeds(...embeds: RestOrArray EmbedBuilder)>): this { + this.data.embeds ??= []; + + const resolved = normalizeArray(embeds).map((embed) => resolveBuilder(embed, EmbedBuilder)); + this.data.embeds.push(...resolved); + + return this; + } + + /** + * Removes, replaces, or inserts embeds for this message. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing embeds of a message. + * @example + * Remove the first embed: + * ```ts + * message.spliceEmbeds(0, 1); + * ``` + * @example + * Remove the first n embeds: + * ```ts + * const n = 4; + * message.spliceEmbeds(0, n); + * ``` + * @example + * Remove the last embed: + * ```ts + * message.spliceEmbeds(-1, 1); + * ``` + * @param start - The index to start at + * @param deleteCount - The amount of embeds to remove + * @param embeds - The embeds to insert + */ + public spliceEmbeds( + start: number, + deleteCount: number, + ...embeds: RestOrArray EmbedBuilder)> + ): this { + this.data.embeds ??= []; + const resolved = normalizeArray(embeds).map((embed) => resolveBuilder(embed, EmbedBuilder)); + + this.data.embeds.splice(start, deleteCount, ...resolved); + return this; + } + + /** + * Sets the allowed mentions for this message. + * + * @param allowedMentions - The allowed mentions to set + */ + public setAllowedMentions( + allowedMentions: + | AllowedMentionsBuilder + | APIAllowedMentions + | ((builder: AllowedMentionsBuilder) => AllowedMentionsBuilder), + ): this { + this.data.allowed_mentions = resolveBuilder(allowedMentions, AllowedMentionsBuilder); + return this; + } + + /** + * Updates the allowed mentions for this message (and creates it if it doesn't exist) + * + * @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()); + return this; + } + + /** + * Clears the allowed mentions for this message. + */ + public clearAllowedMentions(): this { + this.data.allowed_mentions = undefined; + return this; + } + + /** + * Sets the message reference for this message. + * + * @param reference - The reference to set + */ + public setMessageReference( + reference: + | APIMessageReference + | MessageReferenceBuilder + | ((builder: MessageReferenceBuilder) => MessageReferenceBuilder), + ): this { + this.data.message_reference = resolveBuilder(reference, MessageReferenceBuilder); + return this; + } + + /** + * Updates the message reference for this message (and creates it if it doesn't exist) + * + * @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()); + return this; + } + + /** + * Clears the message reference for this message. + */ + public clearMessageReference(): this { + this.data.message_reference = undefined; + return this; + } + + /** + * Adds components to this message. + * + * @param components - The components to add + */ + public addComponents( + ...components: RestOrArray< + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) + > + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)); + this.data.components.push(...resolved); + + return this; + } + + /** + * Removes, replaces, or inserts components for this message. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing components of a message. + * @example + * Remove the first component: + * ```ts + * message.spliceComponents(0, 1); + * ``` + * @example + * Remove the first n components: + * ```ts + * const n = 4; + * message.spliceComponents(0, n); + * ``` + * @example + * Remove the last component: + * ```ts + * message.spliceComponents(-1, 1); + * ``` + * @param start - The index to start at + * @param deleteCount - The amount of components to remove + * @param components - The components to insert + */ + public spliceComponents( + start: number, + deleteCount: number, + ...components: RestOrArray< + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) + > + ): this { + this.data.components ??= []; + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)); + + 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 + | ((builder: ActionRowBuilder) => ActionRowBuilder) + > + ): this { + this.data.components = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)); + return this; + } + + /** + * Sets the sticker ids of this message. + * + * @param stickerIds - The ids of the stickers to set + */ + public setStickerIds(...stickerIds: RestOrArray): this { + this.data.sticker_ids = normalizeArray(stickerIds) as MessageBuilderData['sticker_ids']; + return this; + } + + /** + * Adds sticker ids to this message. + * + * @param stickerIds - The ids of the stickers to add + */ + public addStickerIds(...stickerIds: RestOrArray): this { + this.data.sticker_ids ??= [] as unknown as MessageBuilderData['sticker_ids']; + this.data.sticker_ids!.push(...normalizeArray(stickerIds)); + return this; + } + + /** + * Removes, replaces, or inserts sticker ids for this message. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing sticker ids of a message. + * @example + * Remove the first sticker id: + * ```ts + * message.spliceStickerIds(0, 1); + * ``` + * @example + * Remove the first n sticker ids: + * ```ts + * const n = 4; + * message.spliceStickerIds(0, n); + * ``` + * @example + * Remove the last sticker id: + * ```ts + * message.spliceStickerIds(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The amount of sticker ids to remove + * @param stickerIds - The sticker ids to insert + */ + public spliceStickerIds(index: number, deleteCount: number, ...stickerIds: RestOrArray): this { + this.data.sticker_ids ??= [] as unknown as MessageBuilderData['sticker_ids']; + this.data.sticker_ids!.splice(index, deleteCount, ...normalizeArray(stickerIds)); + return this; + } + + /** + * Sets attachments for this message. + * + * @param attachments - The attachments to set + */ + public setAttachments( + ...attachments: RestOrArray AttachmentBuilder)> + ): this { + const resolved = normalizeArray(attachments).map((attachment) => resolveBuilder(attachment, AttachmentBuilder)); + this.data.attachments = resolved; + + return this; + } + + /** + * Adds attachments to this message. + * + * @param attachments - The attachments to add + */ + public addAttachments( + ...attachments: RestOrArray AttachmentBuilder)> + ): this { + const resolved = normalizeArray(attachments).map((attachment) => resolveBuilder(attachment, AttachmentBuilder)); + this.data.attachments.push(...resolved); + + return this; + } + + /** + * Removes, replaces, or inserts attachments for this message. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing attachments of a message. + * @example + * Remove the first attachment: + * ```ts + * message.spliceAttachments(0, 1); + * ``` + * @example + * Remove the first n attachments: + * ```ts + * const n = 4; + * message.spliceAttachments(0, n); + * ``` + * @example + * Remove the last attachment: + * ```ts + * message.spliceAttachments(-1, 1); + * ``` + * @param start - The index to start at + * @param deleteCount - The amount of attachments to remove + * @param attachments - The attachments to insert + */ + public spliceAttachments( + start: number, + deleteCount: number, + ...attachments: RestOrArray AttachmentBuilder)> + ): this { + const resolved = normalizeArray(attachments).map((attachment) => resolveBuilder(attachment, AttachmentBuilder)); + this.data.attachments.splice(start, deleteCount, ...resolved); + + return this; + } + + /** + * Sets the flags for this message. + */ + public setFlags(flags: number): this { + this.data.flags = flags; + return this; + } + + /** + * Clears the flags for this message. + */ + public clearFlags(): this { + this.data.flags = undefined; + return this; + } + + /** + * Sets enforce_nonce for this message. + */ + public setEnforceNonce(enforceNonce = true): this { + this.data.enforce_nonce = enforceNonce; + return this; + } + + /** + * Sets the poll for this message. + * + * @param poll - The poll to set + */ + public setPoll(poll: APIPoll | PollBuilder | ((builder: PollBuilder) => PollBuilder)): this { + this.data.poll = resolveBuilder(poll, PollBuilder); + return this; + } + + /** + * Updates the poll for this message (and creates it if it doesn't exist) + * + * @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()); + return this; + } + + /** + * Clears the poll for this message. + */ + public clearPoll(): this { + this.data.poll = 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): RESTPostAPIChannelMessageJSONBody { + const { poll, allowed_mentions, attachments, embeds, components, message_reference, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + // Covered by the messagePredicate + poll: this.data.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)), + components: this.data.components?.map((component) => component.toJSON(validationOverride)), + message_reference: message_reference?.toJSON(false), + }; + + validate(messagePredicate, data, validationOverride); + + return data as RESTPostAPIChannelMessageJSONBody; + } +} diff --git a/packages/builders/src/messages/MessageReference.ts b/packages/builders/src/messages/MessageReference.ts new file mode 100644 index 000000000000..bea6851a85ae --- /dev/null +++ b/packages/builders/src/messages/MessageReference.ts @@ -0,0 +1,98 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { APIMessageReference, MessageReferenceType, Snowflake } from 'discord-api-types/v10'; +import { validate } from '../util/validation.js'; +import { messageReferencePredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for message references. + */ +export class MessageReferenceBuilder implements JSONEncodable { + private readonly data: Partial; + + /** + * Creates new allowed mention builder from API data. + * + * @param data - The API data to create this attachment with + */ + public constructor(data: Partial = {}) { + this.data = structuredClone(data); + } + + /** + * Sets the types of message reference this represents + * + * @param type - The type of message reference + */ + public setType(type: MessageReferenceType): this { + this.data.type = type; + return this; + } + + /** + * Clear the type of message reference this represents + */ + public clearType(): this { + this.data.type = undefined; + return this; + } + + /** + * Sets the ID of the message being referenced + * + * @param messageId - The ID of the message being referenced + */ + public setMessageId(messageId: Snowflake): this { + this.data.message_id = messageId; + return this; + } + + /** + * Clear the ID of the message being referenced + */ + public clearMessageId(): this { + this.data.message_id = undefined; + return this; + } + + /** + * Sets the ID of the channel being referenced + * + * @param channelId - The ID of the channel being referenced + */ + public setChannelId(channelId: Snowflake): this { + this.data.channel_id = channelId; + return this; + } + + /** + * Clear the ID of the channel being referenced + */ + public clearChannelId(): this { + this.data.channel_id = undefined; + return this; + } + + /** + * Sets the ID of the guild being referenced + * + * @param guildId - The ID of the guild being referenced + */ + public setGuildId(guildId: Snowflake): this { + this.data.guild_id = guildId; + 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): APIMessageReference { + const clone = structuredClone(this.data); + validate(messageReferencePredicate, clone, validationOverride); + + return clone as APIMessageReference; + } +} From a445a6c0df01f96f28940fbc7fbef0a026b67d4a Mon Sep 17 00:00:00 2001 From: Denis-Adrian Cristea Date: Fri, 7 Mar 2025 13:09:44 +0200 Subject: [PATCH 3/8] chore: nits Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- .../builders/src/messages/AllowedMentions.ts | 8 ++++---- packages/builders/src/messages/Message.ts | 10 +++++----- .../builders/src/messages/MessageReference.ts | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/builders/src/messages/AllowedMentions.ts b/packages/builders/src/messages/AllowedMentions.ts index 3f126a3b6d81..b5439aaff3e3 100644 --- a/packages/builders/src/messages/AllowedMentions.ts +++ b/packages/builders/src/messages/AllowedMentions.ts @@ -64,7 +64,7 @@ export class AllowedMentionsBuilder implements JSONEncodable * * @remarks * This method behaves similarly - * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. * * It's useful for modifying and adjusting order of the already-existing roles. * @example @@ -85,7 +85,7 @@ export class AllowedMentionsBuilder implements JSONEncodable * ``` * @param index - The index to start at * @param deleteCount - The number of roles to remove - * @param roles - The replacing role IDs + * @param roles - The replacing role ids */ public spliceRoles(index: number, deleteCount: number, ...roles: RestOrArray): this { this.data.roles ??= []; @@ -119,7 +119,7 @@ export class AllowedMentionsBuilder implements JSONEncodable * * @remarks * This method behaves similarly - * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. * * It's useful for modifying and adjusting order of the already-existing users. * @example @@ -140,7 +140,7 @@ export class AllowedMentionsBuilder implements JSONEncodable * ``` * @param index - The index to start at * @param deleteCount - The number of users to remove - * @param users - The replacing user IDs + * @param users - The replacing user ids */ public spliceUsers(index: number, deleteCount: number, ...users: RestOrArray): this { this.data.users ??= []; diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index ec12b501dc21..3883efcc4966 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -161,7 +161,7 @@ export class MessageBuilder implements JSONEncodable Date: Mon, 10 Mar 2025 12:05:22 +0200 Subject: [PATCH 4/8] fix: nonce assertion --- packages/builders/src/messages/Assertions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts index 4cd473572dd8..f7051671f95d 100644 --- a/packages/builders/src/messages/Assertions.ts +++ b/packages/builders/src/messages/Assertions.ts @@ -30,7 +30,7 @@ export const messageReferencePredicate = z.object({ export const messagePredicate = z .object({ content: z.string().optional(), - nonce: z.union([z.string(), z.number()]).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(), @@ -73,5 +73,5 @@ export const messagePredicate = z (data.sticker_ids !== undefined && data.sticker_ids.length > 0) ); }, - { message: 'Messages just have at least one field set' }, + { message: 'Messages must have content, embeds, a poll, attachments, components, or stickers' }, ); From edd4d92d7fa65c79f45b06ea657dae46b896fa27 Mon Sep 17 00:00:00 2001 From: didinele Date: Tue, 11 Mar 2025 09:36:15 +0200 Subject: [PATCH 5/8] chore: strip bad method --- packages/builders/src/messages/AllowedMentions.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/builders/src/messages/AllowedMentions.ts b/packages/builders/src/messages/AllowedMentions.ts index b5439aaff3e3..6491b9dbd4e1 100644 --- a/packages/builders/src/messages/AllowedMentions.ts +++ b/packages/builders/src/messages/AllowedMentions.ts @@ -29,14 +29,6 @@ export class AllowedMentionsBuilder implements JSONEncodable return this; } - /** - * Clear the types of mentions to parse from the content. - */ - public clearParse(): this { - this.data.parse = []; - return this; - } - /** * Sets the roles to mention. * From 693550a4d2939c1c82be55f9f9aa1540e0da405a Mon Sep 17 00:00:00 2001 From: Denis-Adrian Cristea Date: Tue, 11 Mar 2025 10:00:41 +0200 Subject: [PATCH 6/8] chore: nit Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- packages/builders/src/messages/Message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index 3883efcc4966..a305947c1770 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -133,7 +133,7 @@ export class MessageBuilder implements JSONEncodable Date: Tue, 11 Mar 2025 15:29:50 +0200 Subject: [PATCH 7/8] chore: nits --- packages/builders/src/messages/Message.ts | 12 +++++++----- packages/builders/src/messages/MessageReference.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index a305947c1770..9bfcfd42c8cc 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -9,6 +9,7 @@ import type { APIPoll, RESTPostAPIChannelMessageJSONBody, Snowflake, + MessageFlags, } from 'discord-api-types/v10'; import { ActionRowBuilder } from '../components/ActionRow.js'; import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; @@ -30,7 +31,7 @@ export interface MessageBuilderData > { allowed_mentions?: AllowedMentionsBuilder; attachments: AttachmentBuilder[]; - components?: ActionRowBuilder[]; + components: ActionRowBuilder[]; embeds: EmbedBuilder[]; message_reference?: MessageReferenceBuilder; poll?: PollBuilder; @@ -53,7 +54,7 @@ export class MessageBuilder implements JSONEncodable attachment.toJSON(false)), embeds: this.data.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)), message_reference: message_reference?.toJSON(false), }; diff --git a/packages/builders/src/messages/MessageReference.ts b/packages/builders/src/messages/MessageReference.ts index c9392ec9a695..0c7cddc6d10d 100644 --- a/packages/builders/src/messages/MessageReference.ts +++ b/packages/builders/src/messages/MessageReference.ts @@ -82,6 +82,14 @@ export class MessageReferenceBuilder implements JSONEncodable Date: Thu, 13 Mar 2025 16:21:06 +0200 Subject: [PATCH 8/8] chore: address final review --- .../builders/src/messages/MessageReference.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/builders/src/messages/MessageReference.ts b/packages/builders/src/messages/MessageReference.ts index 0c7cddc6d10d..84c499362e4c 100644 --- a/packages/builders/src/messages/MessageReference.ts +++ b/packages/builders/src/messages/MessageReference.ts @@ -3,18 +3,22 @@ import type { APIMessageReference, MessageReferenceType, Snowflake } from 'disco 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. * * @param data - The API data to create this attachment with */ - public constructor(data: Partial = {}) { + public constructor(data: Partial = {}) { this.data = structuredClone(data); } @@ -46,14 +50,6 @@ export class MessageReferenceBuilder implements JSONEncodable