feat(builders): multipart form data output support (#11248)

* feat(builders): multipart form data output support

* refactor: proper key management

* chore: add missing remarks

* chore: requested changes

* chore: rename encodables file

* chore: requested changes

* chore: requested changes

* chore: nits

Co-authored-by: Almeida <github@almeidx.dev>

* chore: requested change

* chore: requested change

---------

Co-authored-by: Almeida <github@almeidx.dev>
This commit is contained in:
Denis-Adrian Cristea
2025-11-19 23:59:51 +02:00
committed by GitHub
parent 315f422781
commit 68bb8af58a
10 changed files with 277 additions and 50 deletions

View File

@@ -97,6 +97,9 @@ export * from './util/ValidationError.js';
export * from './Assertions.js';
// We expose this type in our public API. We shouldn't assume every user of builders is also using REST
export type { RawFile } from '@discordjs/util';
/**
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/builders#readme | @discordjs/builders} version
* that you are currently using.

View File

@@ -1,9 +1,20 @@
import { Buffer } from 'node:buffer';
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';
const fileKeyRegex = /^files\[(?<placeholder>\d+?)]$/;
export const rawFilePredicate = z.object({
data: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array), z.string()]),
name: z.string().min(1),
contentType: z.string().optional(),
key: z.string().regex(fileKeyRegex).optional(),
});
export const attachmentPredicate = z.object({
// As a string it only makes sense for edits when we do have an attachment snowflake
id: z.union([z.string(), z.number()]),
description: z.string().max(1_024).optional(),
duration_secs: z
@@ -125,3 +136,11 @@ const messageComponentsV2Predicate = baseMessagePredicate.extend({
});
export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]);
// This validator does not assert file.key <-> attachment.id coherence. This is fine, because the builders
// should effectively guarantee that.
export const fileBodyMessagePredicate = z.object({
body: messagePredicate,
// No min length to support message edits
files: rawFilePredicate.array().max(10),
});

View File

@@ -1,4 +1,5 @@
import type { JSONEncodable } from '@discordjs/util';
import type { Buffer } from 'node:buffer';
import type { JSONEncodable, RawFile } from '@discordjs/util';
import type { RESTAPIAttachment, Snowflake } from 'discord-api-types/v10';
import { validate } from '../util/validation.js';
import { attachmentPredicate } from './Assertions.js';
@@ -12,6 +13,17 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
*/
private readonly data: Partial<RESTAPIAttachment>;
/**
* This data is not included in the output of `toJSON()`. For this class specifically, this refers to binary data
* that will wind up being included in the multipart/form-data request, if used with the `MessageBuilder`.
* To retrieve this data, use {@link getRawFile}.
*
* @remarks This cannot be set via the constructor, primarily because of the behavior described
* {@link https://discord.com/developers/docs/reference#editing-message-attachments | here}.
* That is, when editing a message's attachments, you should only be providing file data for new attachments.
*/
private readonly fileData: Partial<Pick<RawFile, 'contentType' | 'data'>>;
/**
* Creates a new attachment builder.
*
@@ -19,6 +31,7 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
*/
public constructor(data: Partial<RESTAPIAttachment> = {}) {
this.data = structuredClone(data);
this.fileData = {};
}
/**
@@ -26,7 +39,7 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
*
* @param id - The id of the attachment
*/
public setId(id: Snowflake): this {
public setId(id: Snowflake | number): this {
this.data.id = id;
return this;
}
@@ -85,6 +98,60 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
return this;
}
/**
* Sets the file data to upload with this attachment.
*
* @param data - The file data
* @remarks Note that this data is NOT included in the {@link toJSON} output. To retrieve it, use {@link getRawFile}.
*/
public setFileData(data: Buffer | Uint8Array | string): this {
this.fileData.data = data;
return this;
}
/**
* Clears the file data from this attachment.
*/
public clearFileData(): this {
this.fileData.data = undefined;
return this;
}
/**
* Sets the content type of the file data to upload with this attachment.
*
* @remarks Note that this data is NOT included in the {@link toJSON} output. To retrieve it, use {@link getRawFile}.
*/
public setFileContentType(contentType: string): this {
this.fileData.contentType = contentType;
return this;
}
/**
* Clears the content type of the file data from this attachment.
*/
public clearFileContentType(): this {
this.fileData.contentType = undefined;
return this;
}
/**
* Converts this attachment to a {@link RawFile} for uploading.
*
* @returns A {@link RawFile} object, or `undefined` if no file data is set
*/
public getRawFile(): Partial<RawFile> | undefined {
if (!this.fileData?.data) {
return;
}
return {
...this.fileData,
name: this.data.filename,
key: this.data.id ? `files[${this.data.id}]` : undefined,
};
}
/**
* Sets the title of this attachment.
*

View File

@@ -1,4 +1,4 @@
import type { JSONEncodable } from '@discordjs/util';
import type { FileBodyEncodable, FileBodyEncodableResult, JSONEncodable, RawFile } from '@discordjs/util';
import type {
APIActionRowComponent,
APIAllowedMentions,
@@ -32,7 +32,7 @@ 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 { fileBodyMessagePredicate, messagePredicate } from './Assertions.js';
import { AttachmentBuilder } from './Attachment.js';
import { MessageReferenceBuilder } from './MessageReference.js';
import { EmbedBuilder } from './embed/Embed.js';
@@ -56,7 +56,9 @@ export interface MessageBuilderData
/**
* A builder that creates API-compatible JSON data for messages.
*/
export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJSONBody> {
export class MessageBuilder
implements JSONEncodable<RESTPostAPIChannelMessageJSONBody>, FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>
{
/**
* The API data associated with this message.
*/
@@ -661,4 +663,31 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
return data as RESTPostAPIChannelMessageJSONBody;
}
/**
* Serializes this builder to both JSON body and file data for multipart/form-data requests.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
* @remarks
* This method extracts file data from attachments that have files set via {@link AttachmentBuilder.setFileData}.
* The returned body includes attachment metadata, while files contains the binary data for upload.
*/
public toFileBody(validationOverride?: boolean): FileBodyEncodableResult<RESTPostAPIChannelMessageJSONBody> {
const body = this.toJSON(false);
const files: RawFile[] = [];
for (const attachment of this.data.attachments) {
const rawFile = attachment.getRawFile();
// Only if data or content type are set, since that implies the intent is to send a new file.
// In case it's contentType but not data, a validation error will be thrown right after.
if (rawFile?.data || rawFile?.contentType) {
files.push(rawFile as RawFile);
}
}
const combined = { body, files };
validate(fileBodyMessagePredicate, combined, validationOverride);
return combined as FileBodyEncodableResult<RESTPostAPIChannelMessageJSONBody>;
}
}