mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 21:16:06 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user