fix: preserve discord multipart content type

This commit is contained in:
Peter Steinberger
2026-05-02 11:35:29 +01:00
parent 053b7900bb
commit 8f94bd9984
4 changed files with 123 additions and 35 deletions

View File

@@ -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.

View File

@@ -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(

View File

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

View File

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