From 0ae24557e543fd87aac64a434124ca50bdddf00b Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 8 Oct 2026 14:13:51 +0200 Subject: [PATCH 1/2] feat(plugin-gateway): add AttachmentBuilder Add discord.js's AttachmentBuilder (setFile, setName, setDescription, setSpoiler, setTitle, setDuration, setWaveform, toJSON, from) to build the files of a message, and Attachment#attachment as an alias of the URL of a received attachment. --- .changeset/gateway-attachment-builder.md | 5 + packages/plugin-gateway/README.md | 10 + .../src/structures/messages/Attachment.ts | 8 + .../structures/messages/AttachmentBuilder.ts | 216 ++++++++++++++++++ .../src/structures/messages/index.ts | 1 + .../tests/attachment-builder.test.ts | 146 ++++++++++++ .../plugin-gateway/tests/types/contracts.ts | 28 ++- 7 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 .changeset/gateway-attachment-builder.md create mode 100644 packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts create mode 100644 packages/plugin-gateway/tests/attachment-builder.test.ts diff --git a/.changeset/gateway-attachment-builder.md b/.changeset/gateway-attachment-builder.md new file mode 100644 index 00000000..71e6f185 --- /dev/null +++ b/.changeset/gateway-attachment-builder.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add `AttachmentBuilder` to `@wolfstar/plugin-gateway`, like discord.js: `new AttachmentBuilder(file, { name, description })` with the chainable `setFile`, `setName`, `setDescription`, `setSpoiler`, `setTitle`, `setDuration` and `setWaveform`, the `spoiler` getter, `toJSON()`, and `AttachmentBuilder.from(other)` to copy a builder, a payload or a received `Attachment`. Pass it in the `files` of a message. `Attachment#attachment` now returns the URL of a received attachment, as in discord.js. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index 9c5480a4..de8df19f 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -1049,6 +1049,16 @@ DM (`OmitPartialGroupDMChannel`), `fetch(force)` answers from the cache when `fo channel or its ID, and `messageSnapshots` hold `MessageSnapshot`s. `sharedClientTheme`, `resolveComponent(customId)`, and `fetchWebhook()` are discord.js's. +Files to send are `AttachmentBuilder`s, as in discord.js: `new AttachmentBuilder(file, { name, description })` takes +a buffer, a path, a URL, a stream, or a blob, and `setFile`, `setName`, `setDescription`, `setSpoiler` (which adds or removes +the `SPOILER_` prefix), `setTitle`, `setDuration`, and `setWaveform` (for voice messages) chain. Pass them in the `files` of a +message, and copy a builder, a payload, or a received `Attachment` (whose `attachment` is its URL) with `AttachmentBuilder.from`. + +```ts +const file = new AttachmentBuilder("./howl.ogg").setDescription("A howl").setSpoiler(); +await channel.send({ content: "Awoo", files: [file] }); +``` + As in discord.js, `attachments`, `stickers`, `messageSnapshots`, and `reactions.cache` are `Collection`s keyed by ID (reactions by the ID of a custom emoji, the name of a Unicode one), while `embeds` and `components` are arrays. `react()` resolves to the `MessageReaction`, counting the bot. diff --git a/packages/plugin-gateway/src/structures/messages/Attachment.ts b/packages/plugin-gateway/src/structures/messages/Attachment.ts index 0be7f041..22c7951c 100644 --- a/packages/plugin-gateway/src/structures/messages/Attachment.ts +++ b/packages/plugin-gateway/src/structures/messages/Attachment.ts @@ -19,6 +19,14 @@ export class Attachment extends BaseAttachment { initStructure(this, data, relations); } + /** + * The URL of the file, like discord.js's `Attachment#attachment`, so that an attachment can be resent with + * `AttachmentBuilder.from`. + */ + public get attachment() { + return this.url; + } + /** * The name of the file. */ diff --git a/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts b/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts new file mode 100644 index 00000000..984ff381 --- /dev/null +++ b/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts @@ -0,0 +1,216 @@ +/* + * Adapted from discord.js's `AttachmentBuilder` + * (https://github.com/discordjs/discord.js/blob/main/packages/discord.js/src/structures/AttachmentBuilder.js). + * Copyright 2021 Noel Buechler, Copyright 2015 Amish Shah. Licensed under the Apache License, Version 2.0. + */ + +import { basename } from "node:path"; +import type { AttachmentPayload, BufferResolvable, JSONEncodable } from "./MessagePayload.js"; + +const SpoilerPrefix = "SPOILER_"; + +/** + * The optional data of an {@link AttachmentBuilder}. + */ +export interface AttachmentBuilderData { + /** + * The name of the file, inferred from a path or URL when omitted. + */ + name?: string; + /** + * The description (alt text) of the file. + */ + description?: string; +} + +/** + * What {@link AttachmentBuilder.from} copies: an {@link AttachmentPayload}, an {@link AttachmentBuilder}, or a received + * `Attachment`, whose `attachment` is its URL. + */ +export interface AttachmentBuilderSource { + attachment: BufferResolvable; + name?: string | null; + description?: string | null; + title?: string | null; + duration?: number | null; + waveform?: string | null; + spoiler?: boolean; +} + +/** + * Builds a file to send with a message, like discord.js's `AttachmentBuilder`. Pass it in the `files` of a message. + * + * @example + * ```ts + * const file = new AttachmentBuilder("./howl.ogg").setDescription("A howl").setSpoiler(); + * await channel.send({ content: "Awoo", files: [file] }); + * ``` + */ +export class AttachmentBuilder implements JSONEncodable { + /** + * The contents of the file: its data, a path on disk, a URL, a stream, or a blob. + */ + public attachment: BufferResolvable; + + /** + * The name of the file, `undefined` to infer it from a path or URL. + */ + public name?: string; + + /** + * The description (alt text) of the file. + */ + public description?: string; + + /** + * The title of the file. + */ + public title?: string; + + /** + * The duration of a voice message, in seconds. + */ + public duration?: number; + + /** + * The waveform of a voice message, base64-encoded. + */ + public waveform?: string; + + /** + * Whether the file is a spoiler while it has no name to prefix with `SPOILER_`. + */ + private pendingSpoiler = false; + + /** + * @param attachment The contents of the file. + * @param data The name and description of the file. + */ + public constructor(attachment: BufferResolvable, data: AttachmentBuilderData = {}) { + this.attachment = attachment; + this.name = data.name; + this.description = data.description; + } + + /** + * Whether the file is a spoiler: its name starts with `SPOILER_`. + */ + public get spoiler(): boolean { + return this.name === undefined + ? this.pendingSpoiler + : basename(this.name).startsWith(SpoilerPrefix); + } + + /** + * Sets the description (alt text) of the file. + * + * @param description The description. + */ + public setDescription(description: string): this { + this.description = description; + return this; + } + + /** + * Sets the contents of the file, and its name when given. + * + * @param attachment The contents of the file. + * @param name The name of the file, kept as is when omitted. + */ + public setFile(attachment: BufferResolvable, name?: string): this { + this.attachment = attachment; + if (name !== undefined) this.setName(name); + return this; + } + + /** + * Sets the name of the file. + * + * @param name The name of the file. + */ + public setName(name: string): this { + this.name = name; + if (this.pendingSpoiler) { + this.pendingSpoiler = false; + this.setSpoiler(); + } + return this; + } + + /** + * Marks the file as a spoiler, or not, by adding or removing the `SPOILER_` prefix of its name. + * + * @param spoiler Whether the file is a spoiler. + */ + public setSpoiler(spoiler = true): this { + if (this.name === undefined) { + this.pendingSpoiler = spoiler; + } else if (!spoiler) { + while (this.name.startsWith(SpoilerPrefix)) this.name = this.name.slice(SpoilerPrefix.length); + } else if (!this.name.startsWith(SpoilerPrefix)) { + this.name = `${SpoilerPrefix}${this.name}`; + } + return this; + } + + /** + * Sets the title of the file. + * + * @param title The title. + */ + public setTitle(title: string): this { + this.title = title; + return this; + } + + /** + * Sets the duration of a voice message. + * + * @param duration The duration, in seconds. + */ + public setDuration(duration: number): this { + this.duration = duration; + return this; + } + + /** + * Sets the waveform of a voice message. + * + * @param waveform The waveform, base64-encoded. + */ + public setWaveform(waveform: string): this { + this.waveform = waveform; + return this; + } + + /** + * The file as an {@link AttachmentPayload}. + */ + public toJSON(): AttachmentPayload { + const payload: AttachmentPayload = { attachment: this.attachment }; + if (this.name !== undefined) payload.name = this.name; + if (this.description !== undefined) payload.description = this.description; + if (this.title !== undefined) payload.title = this.title; + if (this.duration !== undefined) payload.duration = this.duration; + if (this.waveform !== undefined) payload.waveform = this.waveform; + if (this.name === undefined && this.pendingSpoiler) payload.spoiler = true; + return payload; + } + + /** + * Copies a file. + * + * @param other The builder, payload, or received attachment to copy. + */ + public static from(other: AttachmentBuilderSource): AttachmentBuilder { + const builder = new AttachmentBuilder(other.attachment, { + name: other.name ?? undefined, + description: other.description ?? undefined, + }); + if (other.title != null) builder.title = other.title; + if (other.duration != null) builder.duration = other.duration; + if (other.waveform != null) builder.waveform = other.waveform; + if (builder.name === undefined && other.spoiler) builder.pendingSpoiler = true; + return builder; + } +} diff --git a/packages/plugin-gateway/src/structures/messages/index.ts b/packages/plugin-gateway/src/structures/messages/index.ts index af21ea0d..8b3621ea 100644 --- a/packages/plugin-gateway/src/structures/messages/index.ts +++ b/packages/plugin-gateway/src/structures/messages/index.ts @@ -1,4 +1,5 @@ export * from "./Attachment.js"; +export * from "./AttachmentBuilder.js"; export * from "./Embed.js"; export * from "./Message.js"; export * from "./MessageMentions.js"; diff --git a/packages/plugin-gateway/tests/attachment-builder.test.ts b/packages/plugin-gateway/tests/attachment-builder.test.ts new file mode 100644 index 00000000..612037cd --- /dev/null +++ b/packages/plugin-gateway/tests/attachment-builder.test.ts @@ -0,0 +1,146 @@ +import { createInMemoryCache } from "@wolfstar/plugin-cache"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + Attachment, + AttachmentBuilder, + GatewayClient, + MessagePayload, + type GatewayClientOptions, +} from "../src/index.js"; + +function createClient(options: Partial = {}) { + return new GatewayClient({ + discordPublicKey: "0".repeat(64), + discordToken: "test-token", + clientId: "266624760782258186", + intents: 0, + cache: createInMemoryCache(), + ...options, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("AttachmentBuilder", () => { + test("GIVEN a file and data THEN they are kept and serialized", () => { + const file = new Uint8Array([1]); + const builder = new AttachmentBuilder(file, { name: "howl.ogg", description: "A howl" }); + + expect(builder.attachment).toBe(file); + expect(builder.name).toBe("howl.ogg"); + expect(builder.description).toBe("A howl"); + expect(builder.toJSON()).toEqual({ + attachment: file, + name: "howl.ogg", + description: "A howl", + }); + }); + + test("GIVEN the setters THEN they chain", () => { + const file = new Uint8Array([2]); + const builder = new AttachmentBuilder(new Uint8Array([1])) + .setFile(file, "pack.png") + .setDescription("The pack") + .setTitle("Pack") + .setDuration(3) + .setWaveform("AAAA"); + + expect(builder.toJSON()).toEqual({ + attachment: file, + name: "pack.png", + description: "The pack", + title: "Pack", + duration: 3, + waveform: "AAAA", + }); + expect(builder.setName("renamed.png").name).toBe("renamed.png"); + expect(builder.setFile("./other.png").name).toBe("renamed.png"); + }); + + test("GIVEN setSpoiler THEN the name is prefixed once, and the prefix is removed by setSpoiler(false)", () => { + const builder = new AttachmentBuilder(new Uint8Array([1]), { name: "howl.ogg" }); + expect(builder.spoiler).toBe(false); + + expect(builder.setSpoiler().name).toBe("SPOILER_howl.ogg"); + expect(builder.setSpoiler(true).name).toBe("SPOILER_howl.ogg"); + expect(builder.spoiler).toBe(true); + + expect(builder.setSpoiler(false).name).toBe("howl.ogg"); + expect(builder.spoiler).toBe(false); + expect( + new AttachmentBuilder("x", { name: "SPOILER_SPOILER_a.png" }).setSpoiler(false).name, + ).toBe("a.png"); + }); + + test("GIVEN a spoiler without a name THEN it waits for the name", () => { + const builder = new AttachmentBuilder("./howl.ogg").setSpoiler(); + expect(builder.spoiler).toBe(true); + expect(builder.name).toBeUndefined(); + expect(builder.toJSON()).toMatchObject({ spoiler: true }); + + expect(builder.setName("howl.ogg").name).toBe("SPOILER_howl.ogg"); + expect(builder.toJSON()).not.toHaveProperty("spoiler"); + }); + + test("GIVEN from THEN it copies a builder, a payload, or a received attachment", () => { + const original = new AttachmentBuilder(new Uint8Array([1]), { + name: "howl.ogg", + description: "A howl", + }).setTitle("Howl"); + const copy = AttachmentBuilder.from(original); + expect(copy).not.toBe(original); + expect(copy.toJSON()).toEqual(original.toJSON()); + + expect(AttachmentBuilder.from({ attachment: "./a.png", name: "a.png" }).toJSON()).toEqual({ + attachment: "./a.png", + name: "a.png", + }); + + const received = new Attachment({ + id: "1", + filename: "SPOILER_pack.png", + description: "The pack", + size: 10, + url: "https://cdn.discordapp.com/attachments/1/1/SPOILER_pack.png", + proxy_url: "https://media.discordapp.net/attachments/1/1/SPOILER_pack.png", + }); + const fromReceived = AttachmentBuilder.from(received); + expect(fromReceived.attachment).toBe(received.url); + expect(fromReceived.name).toBe("SPOILER_pack.png"); + expect(fromReceived.description).toBe("The pack"); + expect(fromReceived.spoiler).toBe(true); + }); + + test("GIVEN a builder in files THEN the message sends and describes it", async () => { + const client = createClient(); + const { body, files } = await MessagePayload.create(client, { + files: [ + new AttachmentBuilder(new Uint8Array([1]), { name: "howl.ogg" }) + .setDescription("A howl") + .setSpoiler(), + new AttachmentBuilder(new Uint8Array([2])).setSpoiler(), + ], + }).resolve(); + + expect(files).toEqual([ + { name: "SPOILER_howl.ogg", data: new Uint8Array([1]) }, + { name: "SPOILER_file.jpg", data: new Uint8Array([2]) }, + ]); + expect(body.attachments).toEqual([{ id: "0", description: "A howl" }, { id: "1" }]); + }); +}); + +describe("Attachment", () => { + test("GIVEN a received attachment THEN attachment is its url, like discord.js", () => { + const attachment = new Attachment({ + id: "1", + filename: "pack.png", + size: 10, + url: "https://cdn.discordapp.com/attachments/1/1/pack.png", + proxy_url: "https://media.discordapp.net/attachments/1/1/pack.png", + }); + expect(attachment.attachment).toBe(attachment.url); + }); +}); diff --git a/packages/plugin-gateway/tests/types/contracts.ts b/packages/plugin-gateway/tests/types/contracts.ts index e0869c9b..74c7fa48 100644 --- a/packages/plugin-gateway/tests/types/contracts.ts +++ b/packages/plugin-gateway/tests/types/contracts.ts @@ -1,22 +1,34 @@ // Type-level test, checked by the root `typecheck` script through `tsconfig.consumption.json`: the members sharing a // name with discord.js have its return types. import type { Collection } from "@discordjs/collection"; -import type { - Attachment, - GatewayEventMap, - Message, - MessageReaction, - MessageSnapshot, - Sticker, - User, +import { + AttachmentBuilder, + type Attachment, + type AttachmentPayload, + type BufferResolvable, + type GatewayEventMap, + type Message, + type MessageReaction, + type MessageSnapshot, + type Sticker, + type User, } from "../../src/index.js"; declare const message: Message; declare const reaction: MessageReaction; declare const user: User; +declare const builder: AttachmentBuilder; +declare const received: Attachment; declare const removeAll: GatewayEventMap["messageReactionRemoveAll"]; export const attachments: Collection = message.attachments; +export const builderChain: AttachmentBuilder = builder + .setName("a.png") + .setDescription("A") + .setSpoiler(); +export const builderJSON: AttachmentPayload = builder.toJSON(); +export const builderCopy: AttachmentBuilder = AttachmentBuilder.from(received); +export const receivedFile: BufferResolvable = received.attachment; export const stickers: Collection = message.stickers; export const snapshots: Collection = message.messageSnapshots; export const reactions: Collection = message.reactions.cache; From 2b297558c5551a9940861c0c21d46142148adf84 Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 8 Oct 2026 17:44:41 +0200 Subject: [PATCH 2/2] fix(plugin-gateway): keep the spoiler of a copied attachment AttachmentBuilder.from only carried a spoiler over when the source had no name, so a named payload with spoiler: true, or a received attachment with the IsSpoiler flag but no SPOILER_ prefix, was copied as a regular file. It now calls setSpoiler(), which handles both cases. --- .../structures/messages/AttachmentBuilder.ts | 2 +- .../tests/attachment-builder.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts b/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts index 984ff381..39ecb79d 100644 --- a/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts +++ b/packages/plugin-gateway/src/structures/messages/AttachmentBuilder.ts @@ -210,7 +210,7 @@ export class AttachmentBuilder implements JSONEncodable { if (other.title != null) builder.title = other.title; if (other.duration != null) builder.duration = other.duration; if (other.waveform != null) builder.waveform = other.waveform; - if (builder.name === undefined && other.spoiler) builder.pendingSpoiler = true; + if (other.spoiler) builder.setSpoiler(); return builder; } } diff --git a/packages/plugin-gateway/tests/attachment-builder.test.ts b/packages/plugin-gateway/tests/attachment-builder.test.ts index 612037cd..30c5670d 100644 --- a/packages/plugin-gateway/tests/attachment-builder.test.ts +++ b/packages/plugin-gateway/tests/attachment-builder.test.ts @@ -1,4 +1,5 @@ import { createInMemoryCache } from "@wolfstar/plugin-cache"; +import { AttachmentFlags } from "discord-api-types/v10"; import { afterEach, describe, expect, test, vi } from "vitest"; import { Attachment, @@ -113,6 +114,28 @@ describe("AttachmentBuilder", () => { expect(fromReceived.spoiler).toBe(true); }); + test("GIVEN from a spoiler THEN the copy is a spoiler, named or not", () => { + const named = AttachmentBuilder.from({ attachment: "./a.png", name: "a.png", spoiler: true }); + expect(named.name).toBe("SPOILER_a.png"); + expect(named.spoiler).toBe(true); + + const unnamed = AttachmentBuilder.from({ attachment: "./a.png", spoiler: true }); + expect(unnamed.name).toBeUndefined(); + expect(unnamed.toJSON()).toMatchObject({ spoiler: true }); + + const flagged = new Attachment({ + id: "1", + filename: "pack.png", + size: 10, + url: "https://cdn.discordapp.com/attachments/1/1/pack.png", + proxy_url: "https://media.discordapp.net/attachments/1/1/pack.png", + flags: AttachmentFlags.IsSpoiler, + }); + expect(AttachmentBuilder.from(flagged).name).toBe("SPOILER_pack.png"); + + expect(AttachmentBuilder.from({ attachment: "./a.png", name: "a.png" }).spoiler).toBe(false); + }); + test("GIVEN a builder in files THEN the message sends and describes it", async () => { const client = createClient(); const { body, files } = await MessagePayload.create(client, {