From 6595d1756b1a68fb79a51162ce4dfd2c95bb6983 Mon Sep 17 00:00:00 2001 From: xingzhou Date: Sat, 11 Jul 2026 12:40:41 +0800 Subject: [PATCH] fix(discord): stream multipart uploads (#102972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(discord): bound multipart REST uploads * Preserve Discord media upload payload limits * fix(discord): stream multipart uploads --------- Co-authored-by: 张贵萍0668001030 Co-authored-by: Ayaan Zaidi --- extensions/discord/src/internal/rest.test.ts | 40 +++++++--------- extensions/discord/src/internal/rest.ts | 49 +------------------- extensions/discord/src/monitor/rest-fetch.ts | 10 ++-- extensions/discord/src/send.components.ts | 4 +- extensions/discord/src/send.shared.ts | 12 +---- 5 files changed, 24 insertions(+), 91 deletions(-) diff --git a/extensions/discord/src/internal/rest.test.ts b/extensions/discord/src/internal/rest.test.ts index 3a8f9ff4f863..bebf1fac0b76 100644 --- a/extensions/discord/src/internal/rest.test.ts +++ b/extensions/discord/src/internal/rest.test.ts @@ -2,7 +2,6 @@ import { createServer, type Server } from "node:http"; import { gzipSync } from "node:zlib"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { fetch as undiciFetch } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; import { serializeRequestBody } from "./rest-body.js"; import { DiscordError, RateLimitError, RequestClient } from "./rest.js"; @@ -845,19 +844,12 @@ describe("RequestClient", () => { expect(form.get("files[0]")).toBeInstanceOf(Blob); }); - it("dispatches multipart uploads with a multipart/form-data content type", async () => { + it("passes multipart uploads to fetch as FormData", async () => { + const arrayBufferSpy = vi.spyOn(Blob.prototype, "arrayBuffer"); const fetchSpy = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { expect(init?.headers).toBeInstanceOf(Headers); - expect((init!.headers as Headers).get("Content-Type")).toMatch( - /^multipart\/form-data; boundary=/, - ); - expect(init?.body).not.toBeInstanceOf(FormData); - const request = new Request("https://discord.test/upload", { - method: "POST", - headers: init?.headers, - body: init?.body, - }); - expect(request.headers.get("Content-Type")).toMatch(/^multipart\/form-data; boundary=/); + expect((init!.headers as Headers).get("Content-Type")).toBeNull(); + expect(init?.body).toBeInstanceOf(FormData); return new Response(JSON.stringify({ id: "msg" }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -865,16 +857,21 @@ describe("RequestClient", () => { }); const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false }); - await expect( - client.post("/channels/c1/messages", { - body: { - content: "file", - files: [{ name: "a.txt", data: new Uint8Array([1]), contentType: "text/plain" }], - }, - }), - ).resolves.toEqual({ id: "msg" }); + try { + await expect( + client.post("/channels/c1/messages", { + body: { + content: "file", + files: [{ name: "a.txt", data: new Uint8Array([1]), contentType: "text/plain" }], + }, + }), + ).resolves.toEqual({ id: "msg" }); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(arrayBufferSpy).not.toHaveBeenCalled(); + } finally { + arrayBufferSpy.mockRestore(); + } }); it("dispatches multipart uploads through undici fetch with a multipart/form-data content type", async () => { @@ -897,7 +894,6 @@ describe("RequestClient", () => { const client = new RequestClient("test-token", { baseUrl: `http://127.0.0.1:${address.port}`, apiVersion: 10, - fetch: undiciFetch as unknown as typeof fetch, queueRequests: false, }); diff --git a/extensions/discord/src/internal/rest.ts b/extensions/discord/src/internal/rest.ts index ae0c37104f70..7b67586a4cec 100644 --- a/extensions/discord/src/internal/rest.ts +++ b/extensions/discord/src/internal/rest.ts @@ -1,5 +1,4 @@ // Discord plugin module implements rest behavior. -import { randomBytes } from "node:crypto"; import { inspect } from "node:util"; import { gunzipSync } from "node:zlib"; import { @@ -151,52 +150,6 @@ function isZlibMaxOutputLengthError(err: unknown): boolean { ); } -function escapeMultipartQuotedValue(value: string): string { - return value.replace(/["\r\n]/g, (ch) => (ch === '"' ? "%22" : ch === "\r" ? "%0D" : "%0A")); -} - -async function formDataToMultipartBody(body: FormData, headers: Headers): Promise { - const boundary = `----openclaw-discord-${randomBytes(12).toString("hex")}`; - headers.set("Content-Type", `multipart/form-data; boundary=${boundary}`); - const chunks: Buffer[] = []; - const push = (value: string | Buffer) => { - chunks.push(typeof value === "string" ? Buffer.from(value) : value); - }; - for (const [key, value] of body.entries()) { - push(`--${boundary}\r\n`); - const escapedKey = escapeMultipartQuotedValue(key); - if (typeof value === "string") { - push(`Content-Disposition: form-data; name="${escapedKey}"\r\n\r\n`); - push(value); - push("\r\n"); - continue; - } - const filename = (value as Blob & { name?: unknown }).name; - const escapedFilename = escapeMultipartQuotedValue( - typeof filename === "string" && filename.length > 0 ? filename : "blob", - ); - push(`Content-Disposition: form-data; name="${escapedKey}"; filename="${escapedFilename}"\r\n`); - if (value.type) { - push(`Content-Type: ${value.type}\r\n`); - } - push("\r\n"); - push(Buffer.from(await value.arrayBuffer())); - push("\r\n"); - } - push(`--${boundary}--\r\n`); - return Buffer.concat(chunks) as unknown as BodyInit; -} - -async function normalizeFetchBody( - body: BodyInit | undefined, - headers: Headers, -): Promise { - if (body instanceof FormData) { - return await formDataToMultipartBody(body, headers); - } - return body; -} - export class RequestClient { readonly options: NormalizedRequestClientOptions; protected token: string; @@ -297,7 +250,7 @@ export class RequestClient { const response = await (this.customFetch ?? fetch)(url, { method, headers, - body: await normalizeFetchBody(body, headers), + body, signal, }); const text = await readResponseBodyText(response, this.options.timeout ?? 15_000); diff --git a/extensions/discord/src/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts index c7db92c95d49..94026225ab1f 100644 --- a/extensions/discord/src/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -14,7 +14,8 @@ import { import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { Agent, fetch as undiciFetch } from "undici"; +import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch"; +import { Agent } from "undici"; import { createDiscordDnsLookup } from "../network-config.js"; import { withValidatedDiscordProxy } from "../proxy-fetch.js"; @@ -56,12 +57,7 @@ function createEnvProxyDiscordRestDispatcher( function createDiscordRestFetchWithDispatcher(dispatcher: DiscordRestDispatcher): typeof fetch { return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) => - ( - undiciFetch(input as string | URL, { - ...(init as Record), - dispatcher, - }) as unknown as Promise - ).then((response) => { + fetchWithRuntimeDispatcher(input, { ...init, dispatcher }).then((response) => { captureHttpExchange({ url: resolveRequestUrl(input), method: init?.method ?? "GET", diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 5d04ee88b17c..51533a30cf14 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -33,7 +33,6 @@ import { createDiscordClient, resolveChannelId, resolveDiscordChannel, - toDiscordFileBlob, stripUndefinedFields, SUPPRESS_NOTIFICATIONS_FLAG, } from "./send.shared.js"; @@ -218,8 +217,7 @@ async function buildDiscordComponentPayload(params: { const filenameOverride = params.opts.filename?.trim(); resolvedFileName = filenameOverride || media.fileName || "upload"; spec = withImplicitComponentAttachmentBlock(spec, resolvedFileName); - const fileData = toDiscordFileBlob(media.buffer); - files = [{ data: fileData, name: resolvedFileName }]; + files = [{ data: media.buffer, name: resolvedFileName }]; } const attachmentNames = extractComponentAttachmentNames(spec); diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index 341d93b11d66..6aee24e2eefd 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -304,15 +304,6 @@ export function buildDiscordTextChunks( return resolveTextChunksWithFallback(text, chunks); } -export function toDiscordFileBlob(data: Blob | Uint8Array): Blob { - if (data instanceof Blob) { - return data; - } - const arrayBuffer = new ArrayBuffer(data.byteLength); - new Uint8Array(arrayBuffer).set(data); - return new Blob([arrayBuffer]); -} - export type DiscordSendProgress = ( result: { id: string; channel_id: string }, kind: "text" | "media", @@ -450,7 +441,6 @@ async function sendDiscordMedia(params: DiscordMediaSendParams) { ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode, maxChars }) : []; const caption = chunks[0] ?? ""; - const fileData = toDiscordFileBlob(media.buffer); const captionComponents = resolveDiscordSendComponents({ components, text: caption, @@ -470,7 +460,7 @@ async function sendDiscordMedia(params: DiscordMediaSendParams) { replyTo: resolveDiscordReplyMessageId(reply, true), files: [ { - data: fileData, + data: media.buffer, name: resolvedFileName, }, ],