mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-15 11:03:30 +01:00
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:
committed by
GitHub
parent
315f422781
commit
68bb8af58a
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user