refactor!: remove AttachmentBuilder and support new file body encodables (#11278)

BREAKING CHANGE: Remove AttachmentBuilder

---------

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
This commit is contained in:
Denis-Adrian Cristea
2025-12-08 10:19:43 +02:00
committed by GitHub
parent 5888663392
commit 9005c8ae9c
11 changed files with 87 additions and 263 deletions

View File

@@ -28,6 +28,11 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
* Creates a new attachment builder.
*
* @param data - The API data to create this attachment with
* @example
* ```ts
* const attachment = new AttachmentBuilder().setId(1).setFileData(':)').setFilename('smiley.txt')
* ```
* @remarks Please note that the `id` field is required, it's rather easy to miss!
*/
public constructor(data: Partial<RESTAPIAttachment> = {}) {
this.data = structuredClone(data);

View File

@@ -111,7 +111,6 @@ exports.ApplicationEmoji = require('./structures/ApplicationEmoji.js').Applicati
exports.ApplicationRoleConnectionMetadata =
require('./structures/ApplicationRoleConnectionMetadata.js').ApplicationRoleConnectionMetadata;
exports.Attachment = require('./structures/Attachment.js').Attachment;
exports.AttachmentBuilder = require('./structures/AttachmentBuilder.js').AttachmentBuilder;
exports.AutocompleteInteraction = require('./structures/AutocompleteInteraction.js').AutocompleteInteraction;
exports.AutoModerationActionExecution =
require('./structures/AutoModerationActionExecution.js').AutoModerationActionExecution;

View File

@@ -1,7 +1,7 @@
'use strict';
const process = require('node:process');
const { lazy } = require('@discordjs/util');
const { lazy, isFileBodyEncodable, isJSONEncodable } = require('@discordjs/util');
const { Routes } = require('discord-api-types/v10');
const { BaseChannel } = require('../structures/BaseChannel.js');
const { MessagePayload } = require('../structures/MessagePayload.js');
@@ -147,7 +147,7 @@ class ChannelManager extends CachedManager {
* Creates a message in a channel.
*
* @param {TextChannelResolvable} channel The channel to send the message to
* @param {string|MessagePayload|MessageCreateOptions} options The options to provide
* @param {string|MessagePayload|MessageCreateOptions|JSONEncodable<RESTPostAPIChannelMessageJSONBody>|FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>} options The options to provide
* @returns {Promise<Message>}
* @example
* // Send a basic message
@@ -174,18 +174,21 @@ class ChannelManager extends CachedManager {
* .catch(console.error);
*/
async createMessage(channel, options) {
let messagePayload;
let payload;
if (options instanceof MessagePayload) {
messagePayload = options.resolveBody();
payload = await options.resolveBody().resolveFiles();
} else if (isFileBodyEncodable(options)) {
payload = options.toFileBody();
} else if (isJSONEncodable(options)) {
payload = { body: options.toJSON() };
} else {
messagePayload = MessagePayload.create(this, options).resolveBody();
payload = await MessagePayload.create(this, options).resolveBody().resolveFiles();
}
const resolvedChannelId = this.resolveId(channel);
const resolvedChannel = this.resolve(channel);
const { body, files } = await messagePayload.resolveFiles();
const data = await this.client.rest.post(Routes.channelMessages(resolvedChannelId), { body, files });
const data = await this.client.rest.post(Routes.channelMessages(resolvedChannelId), payload);
return resolvedChannel?.messages._add(data) ?? new (getMessage())(this.client, data);
}

View File

@@ -2,6 +2,7 @@
const { Collection } = require('@discordjs/collection');
const { makeURLSearchParams } = require('@discordjs/rest');
const { isFileBodyEncodable, isJSONEncodable } = require('@discordjs/util');
const { Routes } = require('discord-api-types/v10');
const { DiscordjsTypeError, ErrorCodes } = require('../errors/index.js');
const { Message } = require('../structures/Message.js');
@@ -223,21 +224,27 @@ class MessageManager extends CachedManager {
* Edits a message, even if it's not cached.
*
* @param {MessageResolvable} message The message to edit
* @param {string|MessageEditOptions|MessagePayload} options The options to edit the message
* @param {string|MessageEditOptions|MessagePayload|FileBodyEncodable<RESTPatchAPIChannelMessageJSONBody>|JSONEncodable<RESTPatchAPIChannelMessageJSONBody>} options The options to edit the message
* @returns {Promise<Message>}
*/
async edit(message, options) {
const messageId = this.resolveId(message);
if (!messageId) throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'message', 'MessageResolvable');
const { body, files } = await (
options instanceof MessagePayload
? options
: MessagePayload.create(message instanceof Message ? message : this, options)
)
let payload;
if (options instanceof MessagePayload) {
payload = await options.resolveBody().resolveFiles();
} else if (isFileBodyEncodable(options)) {
payload = options.toFileBody();
} else if (isJSONEncodable(options)) {
payload = { body: options.toJSON() };
} else {
payload = await MessagePayload.create(message instanceof Message ? message : this, options)
.resolveBody()
.resolveFiles();
const data = await this.client.rest.patch(Routes.channelMessage(this.channel.id, messageId), { body, files });
}
const data = await this.client.rest.patch(Routes.channelMessage(this.channel.id, messageId), payload);
const existing = this.cache.get(messageId);
if (existing) {

View File

@@ -1,185 +0,0 @@
'use strict';
const { basename, flatten } = require('../util/Util.js');
/**
* Represents an attachment builder
*/
class AttachmentBuilder {
/**
* @param {BufferResolvable|Stream} attachment The file
* @param {AttachmentData} [data] Extra data
*/
constructor(attachment, data = {}) {
/**
* The file associated with this attachment.
*
* @type {BufferResolvable|Stream}
*/
this.attachment = attachment;
/**
* The name of this attachment
*
* @type {?string}
*/
this.name = data.name;
/**
* The description of the attachment
*
* @type {?string}
*/
this.description = data.description;
/**
* The title of the attachment
*
* @type {?string}
*/
this.title = data.title;
/**
* The base64 encoded byte array representing a sampled waveform
* <info>This is only for voice message attachments.</info>
*
* @type {?string}
*/
this.waveform = data.waveform;
/**
* The duration of the attachment in seconds
* <info>This is only for voice message attachments.</info>
*
* @type {?number}
*/
this.duration = data.duration;
}
/**
* Sets the description of this attachment.
*
* @param {string} description The description of the file
* @returns {AttachmentBuilder} This attachment
*/
setDescription(description) {
this.description = description;
return this;
}
/**
* Sets the file of this attachment.
*
* @param {BufferResolvable|Stream} attachment The file
* @returns {AttachmentBuilder} This attachment
*/
setFile(attachment) {
this.attachment = attachment;
return this;
}
/**
* Sets the name of this attachment.
*
* @param {string} name The name of the file
* @returns {AttachmentBuilder} This attachment
*/
setName(name) {
this.name = name;
return this;
}
/**
* Sets the title of this attachment.
*
* @param {string} title The title of the file
* @returns {AttachmentBuilder} This attachment
*/
setTitle(title) {
this.title = title;
return this;
}
/**
* Sets the waveform of this attachment.
* <info>This is only for voice message attachments.</info>
*
* @param {string} waveform The base64 encoded byte array representing a sampled waveform
* @returns {AttachmentBuilder} This attachment
*/
setWaveform(waveform) {
this.waveform = waveform;
return this;
}
/**
* Sets the duration of this attachment.
* <info>This is only for voice message attachments.</info>
*
* @param {number} duration The duration of the attachment in seconds
* @returns {AttachmentBuilder} This attachment
*/
setDuration(duration) {
this.duration = duration;
return this;
}
/**
* Sets whether this attachment is a spoiler
*
* @param {boolean} [spoiler=true] Whether the attachment should be marked as a spoiler
* @returns {AttachmentBuilder} This attachment
*/
setSpoiler(spoiler = true) {
if (spoiler === this.spoiler) return this;
if (!spoiler) {
while (this.spoiler) {
this.name = this.name.slice('SPOILER_'.length);
}
return this;
}
this.name = `SPOILER_${this.name}`;
return this;
}
/**
* Whether or not this attachment has been marked as a spoiler
*
* @type {boolean}
* @readonly
*/
get spoiler() {
return basename(this.name).startsWith('SPOILER_');
}
toJSON() {
return flatten(this);
}
/**
* Makes a new builder instance from a preexisting attachment structure.
*
* @param {AttachmentBuilder|Attachment|AttachmentPayload} other The builder to construct a new instance from
* @returns {AttachmentBuilder}
*/
static from(other) {
return new AttachmentBuilder(other.attachment, {
name: other.name,
description: other.description,
});
}
}
exports.AttachmentBuilder = AttachmentBuilder;
/**
* @typedef {Object} AttachmentData
* @property {string} [name] The name of the attachment
* @property {string} [description] The description of the attachment
* @property {string} [title] The title of the attachment
* @property {string} [waveform] The base64 encoded byte array representing a sampled waveform (for voice message attachments)
* @property {number} [duration] The duration of the attachment in seconds (for voice message attachments)
*/

View File

@@ -849,7 +849,7 @@ class Message extends Base {
/**
* Edits the content of the message.
*
* @param {string|MessagePayload|MessageEditOptions} options The options to provide
* @param {string|MessageEditOptions|MessagePayload|FileBodyEncodable<RESTPatchAPIChannelMessageJSONBody>|JSONEncodable<RESTPatchAPIChannelMessageJSONBody>} options The options to provide
* @returns {Promise<Message>}
* @example
* // Update the content of a message

View File

@@ -197,10 +197,13 @@ class MessagePayload {
waveform: file.waveform,
duration_secs: file.duration,
}));
// Only passable during edits
if (Array.isArray(this.options.attachments)) {
this.options.attachments.push(...(attachments ?? []));
} else {
this.options.attachments = attachments;
attachments.push(
// Note how we don't check for file body encodable, since we aren't expecting file data here
...this.options.attachments.map(attachment => (isJSONEncodable(attachment) ? attachment.toJSON() : attachment)),
);
}
let poll;
@@ -237,7 +240,7 @@ class MessagePayload {
: allowedMentions,
flags,
message_reference,
attachments: this.options.attachments,
attachments,
sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker),
thread_name: threadName,
applied_tags: appliedTags,

View File

@@ -88,7 +88,7 @@ class TextBasedChannel {
* @property {Array<(EmbedBuilder|Embed|APIEmbed)>} [embeds] The embeds for the message
* @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content
* (see {@link https://discord.com/developers/docs/resources/message#allowed-mentions-object here} for more details)
* @property {Array<(AttachmentBuilder|Attachment|AttachmentPayload|BufferResolvable)>} [files]
* @property {Array<(Attachment|AttachmentPayload|BufferResolvable|FileBodyEncodable<APIAttachment>|Stream)>} [files]
* The files to send with the message.
* @property {Array<(ActionRowBuilder|MessageTopLevelComponent|APIMessageTopLevelComponent)>} [components]
* Action rows containing interactive components for the message (buttons, select menus) and other
@@ -156,7 +156,7 @@ class TextBasedChannel {
/**
* Sends a message to this channel.
*
* @param {string|MessagePayload|MessageCreateOptions} options The options to provide
* @param {string|MessagePayload|MessageCreateOptions|JSONEncodable<RESTPostAPIChannelMessageJSONBody>|FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>} options The options to provide
* @returns {Promise<Message>}
* @example
* // Send a basic message

View File

@@ -683,3 +683,13 @@
* @external WebhookType
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/WebhookType}
*/
/**
* @external RESTPatchAPIChannelMessageJSONBody
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/interface/RESTPatchAPIChannelMessageJSONBody}
*/
/**
* @external RESTPostAPIChannelMessageJSONBody
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/interface/RESTPostAPIChannelMessageJSONBody}
*/

View File

@@ -5,7 +5,7 @@ import { MessagePort, Worker } from 'node:worker_threads';
import { ApplicationCommandOptionAllowedChannelType, MessageActionRowComponentBuilder } from '@discordjs/builders';
import { Collection, ReadonlyCollection } from '@discordjs/collection';
import { BaseImageURLOptions, ImageURLOptions, RawFile, REST, RESTOptions, EmojiURLOptions } from '@discordjs/rest';
import { Awaitable, JSONEncodable } from '@discordjs/util';
import { Awaitable, FileBodyEncodable, JSONEncodable } from '@discordjs/util';
import { WebSocketManager, WebSocketManagerOptions } from '@discordjs/ws';
import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter';
import {
@@ -2235,7 +2235,12 @@ export class Message<InGuild extends boolean = boolean> extends Base {
): InteractionCollector<MappedInteractionTypes<InGuild>[ComponentType]>;
public delete(): Promise<OmitPartialGroupDMChannel<Message<InGuild>>>;
public edit(
content: MessageEditOptions | MessagePayload | string,
content:
| FileBodyEncodable<RESTPatchAPIChannelMessageJSONBody>
| JSONEncodable<RESTPatchAPIChannelMessageJSONBody>
| MessageEditOptions
| MessagePayload
| string,
): Promise<OmitPartialGroupDMChannel<Message<InGuild>>>;
public equals(message: Message, rawData: unknown): boolean;
public fetchReference(): Promise<OmitPartialGroupDMChannel<Message<InGuild>>>;
@@ -2260,26 +2265,6 @@ export class Message<InGuild extends boolean = boolean> extends Base {
public inGuild(): this is Message<true>;
}
export class AttachmentBuilder {
public constructor(attachment: BufferResolvable | Stream, data?: AttachmentData);
public attachment: BufferResolvable | Stream;
public description: string | null;
public name: string | null;
public title: string | null;
public waveform: string | null;
public duration: number | null;
public get spoiler(): boolean;
public setDescription(description: string): this;
public setFile(attachment: BufferResolvable | Stream, name?: string): this;
public setName(name: string): this;
public setTitle(title: string): this;
public setWaveform(waveform: string): this;
public setDuration(duration: number): this;
public setSpoiler(spoiler?: boolean): this;
public toJSON(): unknown;
public static from(other: JSONEncodable<AttachmentPayload>): AttachmentBuilder;
}
export class Attachment {
private constructor(data: APIAttachment);
private readonly attachment: BufferResolvable | Stream;
@@ -4280,7 +4265,12 @@ export class ChannelManager extends CachedManager<Snowflake, Channel, ChannelRes
private constructor(client: Client<true>, iterable: Iterable<RawChannelData>);
public createMessage(
channel: Exclude<TextBasedChannelResolvable, PartialGroupDMChannel>,
options: MessageCreateOptions | MessagePayload | string,
options:
| FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>
| JSONEncodable<RESTPostAPIChannelMessageJSONBody>
| MessageCreateOptions
| MessagePayload
| string,
): Promise<OmitPartialGroupDMChannel<Message>>;
public fetch(id: Snowflake, options?: FetchChannelOptions): Promise<Channel | null>;
}
@@ -4632,7 +4622,12 @@ export abstract class MessageManager<InGuild extends boolean = boolean> extends
public delete(message: MessageResolvable): Promise<void>;
public edit(
message: MessageResolvable,
options: MessageEditOptions | MessagePayload | string,
options:
| FileBodyEncodable<RESTPatchAPIChannelMessageJSONBody>
| JSONEncodable<RESTPatchAPIChannelMessageJSONBody>
| MessageEditOptions
| MessagePayload
| string,
): Promise<Message<InGuild>>;
public fetch(options: FetchMessageOptions | MessageResolvable): Promise<Message<InGuild>>;
public fetch(options?: FetchMessagesOptions): Promise<Collection<Snowflake, Message<InGuild>>>;
@@ -4809,7 +4804,14 @@ export class VoiceStateManager extends CachedManager<Snowflake, VoiceState, type
export type Constructable<Entity> = abstract new (...args: any[]) => Entity;
export interface SendMethod<InGuild extends boolean = boolean> {
send(options: MessageCreateOptions | MessagePayload | string): Promise<Message<InGuild>>;
send(
options:
| FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>
| JSONEncodable<RESTPostAPIChannelMessageJSONBody>
| MessageCreateOptions
| MessagePayload
| string,
): Promise<Message<InGuild>>;
}
export interface PinnableChannelFields {
@@ -6277,7 +6279,7 @@ export interface GuildEmojiEditOptions {
export interface GuildStickerCreateOptions {
description?: string | null;
file: AttachmentPayload | BufferResolvable | JSONEncodable<AttachmentBuilder> | Stream;
file: AttachmentPayload | BufferResolvable | Stream;
name: string;
reason?: string;
tags: string;
@@ -6695,14 +6697,7 @@ export interface BaseMessageOptions {
)[];
content?: string;
embeds?: readonly (APIEmbed | JSONEncodable<APIEmbed>)[];
files?: readonly (
| Attachment
| AttachmentBuilder
| AttachmentPayload
| BufferResolvable
| JSONEncodable<APIAttachment>
| Stream
)[];
files?: readonly (Attachment | AttachmentPayload | BufferResolvable | FileBodyEncodable<APIAttachment> | Stream)[];
}
export interface MessageOptionsPoll {
@@ -6742,12 +6737,8 @@ export interface MessageCreateOptions extends BaseMessageCreateOptions {
export interface GuildForumThreadMessageCreateOptions
extends BaseMessageOptions, MessageOptionsFlags, MessageOptionsStickers {}
export interface MessageEditAttachmentData {
id: Snowflake;
}
export interface MessageEditOptions extends Omit<BaseMessageOptions, 'content'> {
attachments?: readonly (Attachment | MessageEditAttachmentData)[];
attachments?: readonly (Attachment | JSONEncodable<APIAttachment>)[];
content?: string | null;
flags?:
| BitFieldResolvable<

View File

@@ -203,7 +203,6 @@ import type {
} from './index.js';
import {
ActionRowBuilder,
AttachmentBuilder,
ChannelSelectMenuBuilder,
Client,
Collection,
@@ -230,6 +229,7 @@ import {
UserSelectMenuComponent,
UserSelectMenuInteraction,
Webhook,
MessageBuilder,
} from './index.js';
// Test type transformation:
@@ -453,15 +453,9 @@ client.on('messageCreate', async message => {
assertIsMessage(client.channels.createMessage(channel, {}));
assertIsMessage(client.channels.createMessage(channel, { embeds: [] }));
const attachment = new AttachmentBuilder('file.png');
const embed = new EmbedBuilder();
assertIsMessage(channel.send({ files: [attachment] }));
assertIsMessage(channel.send({ embeds: [embed] }));
assertIsMessage(channel.send({ embeds: [embed], files: [attachment] }));
assertIsMessage(client.channels.createMessage(channel, { files: [attachment] }));
assertIsMessage(client.channels.createMessage(channel, { embeds: [embed] }));
assertIsMessage(client.channels.createMessage(channel, { embeds: [embed], files: [attachment] }));
if (message.inGuild()) {
expectAssignable<Message<true>>(message);
@@ -3034,14 +3028,11 @@ await guildScheduledEventManager.edit(snowflake, { recurrenceRule: null });
});
}
await textChannel.send({
files: [
new AttachmentBuilder('https://example.com/voice-message.ogg')
.setDuration(2)
.setWaveform('AFUqPDw3Eg2hh4+gopOYj4xthU4='),
],
flags: MessageFlags.IsVoiceMessage,
});
await textChannel.send(
new MessageBuilder()
.setContent(':)')
.addAttachments(attachment => attachment.setId(1).setFileData(':)').setFilename('smiley.txt')),
);
await textChannel.send({
files: [