mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix: preserve discord multipart content type
This commit is contained in:
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Hooks/doctor: warn when `hooks.transformsDir` points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk.
|
||||
- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5.
|
||||
- Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack.
|
||||
- Discord: preserve multipart Content-Type headers for attachment uploads across REST fetch paths, so generated images and other media no longer fail delivery with `CONTENT_TYPE_INVALID`. Thanks @FunJim.
|
||||
- Discord: preserve attachment and sticker filenames when saving inbound media, so agents can see human-readable file names instead of only UUID-based paths. Fixes #59744. Thanks @xela92 and @rockcent.
|
||||
- Discord: preserve non-ASCII channel names in session display labels while keeping allowlist matching on the existing ASCII slug contract. Thanks @swjeong9.
|
||||
- Discord/PluralKit: canonicalize proxied webhook turns to the original Discord message id for inbound dedupe, while preserving the proxy message id for reply routing. Thanks @acgh213.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { serializeRequestBody } from "./rest-body.js";
|
||||
import { RequestClient } from "./rest.js";
|
||||
@@ -406,6 +408,77 @@ describe("RequestClient", () => {
|
||||
expect(form.get("files[0]")).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("dispatches multipart uploads with a multipart/form-data content type", async () => {
|
||||
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=/);
|
||||
return new Response(JSON.stringify({ id: "msg" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
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" });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dispatches multipart uploads through undici fetch with a multipart/form-data content type", async () => {
|
||||
const server = await new Promise<Server>((resolve) => {
|
||||
const srv = createServer((req, res) => {
|
||||
expect(req.headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/);
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ id: "msg" }));
|
||||
});
|
||||
});
|
||||
srv.listen(0, () => resolve(srv));
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server did not bind to a TCP port");
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
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" });
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes form multipart uploads for sticker-style endpoints", () => {
|
||||
const headers = new Headers();
|
||||
const body = serializeRequestBody(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { inspect } from "node:util";
|
||||
import { serializeRequestBody } from "./rest-body.js";
|
||||
import {
|
||||
@@ -74,6 +75,52 @@ function coerceResponseBody(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
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: RequestClientOptions;
|
||||
protected token: string;
|
||||
@@ -155,7 +202,7 @@ export class RequestClient {
|
||||
const response = await (this.customFetch ?? fetch)(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
body: await normalizeFetchBody(body, headers),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
import { FormData as UndiciFormData } from "undici";
|
||||
import { RequestClient, type RequestClientOptions } from "./internal/discord.js";
|
||||
|
||||
type ProxyRequestClientOptions = RequestClientOptions;
|
||||
|
||||
export const DISCORD_REST_TIMEOUT_MS = 15_000;
|
||||
|
||||
function toUndiciFormData(body: FormData): UndiciFormData {
|
||||
const converted = new UndiciFormData();
|
||||
for (const [key, value] of body.entries()) {
|
||||
if (typeof value === "string") {
|
||||
converted.append(key, value);
|
||||
continue;
|
||||
}
|
||||
const filename = (value as Blob & { name?: unknown }).name;
|
||||
if (typeof filename === "string" && filename.length > 0) {
|
||||
converted.append(key, value, filename);
|
||||
continue;
|
||||
}
|
||||
converted.append(key, value);
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
function wrapDiscordFetch(fetchImpl: NonNullable<RequestClientOptions["fetch"]>) {
|
||||
return (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
if (init?.body instanceof FormData) {
|
||||
// The proxy fetch path needs undici's FormData class to preserve multipart
|
||||
// boundaries. Preserve the REST client's AbortController signal so timeout
|
||||
// and abortAllRequests keep working.
|
||||
return fetchImpl(input, {
|
||||
...init,
|
||||
body: toUndiciFormData(init.body) as unknown as BodyInit,
|
||||
});
|
||||
}
|
||||
return fetchImpl(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordRequestClient(
|
||||
token: string,
|
||||
options?: ProxyRequestClientOptions,
|
||||
@@ -49,6 +16,6 @@ export function createDiscordRequestClient(
|
||||
maxQueueSize: 1000,
|
||||
timeout: DISCORD_REST_TIMEOUT_MS,
|
||||
...options,
|
||||
fetch: wrapDiscordFetch(options.fetch),
|
||||
fetch: options.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user