diff --git a/CHANGELOG.md b/CHANGELOG.md index 752593556a2..856eb027b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/internal/rest.test.ts b/extensions/discord/src/internal/rest.test.ts index f7adc6ea81a..aada8d0da95 100644 --- a/extensions/discord/src/internal/rest.test.ts +++ b/extensions/discord/src/internal/rest.test.ts @@ -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((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((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( diff --git a/extensions/discord/src/internal/rest.ts b/extensions/discord/src/internal/rest.ts index 5908da9ab68..b15788d1617 100644 --- a/extensions/discord/src/internal/rest.ts +++ b/extensions/discord/src/internal/rest.ts @@ -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 { + 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: 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(); diff --git a/extensions/discord/src/proxy-request-client.ts b/extensions/discord/src/proxy-request-client.ts index 5f738a4276c..bd6771e77cc 100644 --- a/extensions/discord/src/proxy-request-client.ts +++ b/extensions/discord/src/proxy-request-client.ts @@ -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) { - return (input: string | URL | Request, init?: RequestInit): Promise => { - 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, }); }