fix(discord): stream multipart uploads (#102972)

* fix(discord): bound multipart REST uploads
* Preserve Discord media upload payload limits
* fix(discord): stream multipart uploads

---------

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
xingzhou
2026-07-11 12:40:41 +08:00
committed by GitHub
parent e009a41410
commit 6595d1756b
5 changed files with 24 additions and 91 deletions

View File

@@ -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,
});

View File

@@ -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<BodyInit> {
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<BodyInit | undefined> {
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);

View File

@@ -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<string, unknown>),
dispatcher,
}) as unknown as Promise<Response>
).then((response) => {
fetchWithRuntimeDispatcher(input, { ...init, dispatcher }).then((response) => {
captureHttpExchange({
url: resolveRequestUrl(input),
method: init?.method ?? "GET",

View File

@@ -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);

View File

@@ -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,
},
],