Skip to content

Commit c6bc27f

Browse files
committed
fix(feishu): normalize media upload multipart data
Convert Feishu SDK multipart Buffer upload parts into explicit FormData before they reach the wrapped HTTP transport. The SDK upload helpers pass multipart data as a plain object with Buffer media parts; relying on implicit serialization in the timeout/proxy-aware HTTP wrapper was fragile and caused file/image delivery failures (400 volc-dcdn / write ECONNRESET) even though the same credentials succeeded via a standalone SDK upload. Adds normalizeMultipartUploadData, applied on the request path of the shared Feishu HTTP instance, plus a regression test covering the multipart-to-FormData normalization. Rebased onto current origin/main, which replaced the old synchronous injectTimeout path with the async proxy-aware injectRequestOptions flow; the multipart normalization is now applied before that flow.
1 parent 017217b commit c6bc27f

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

extensions/feishu/src/client.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,40 @@ describe("createFeishuClient HTTP timeout", () => {
325325
});
326326
});
327327

328+
it("normalizes Feishu SDK multipart uploads to explicit FormData", async () => {
329+
createFeishuClient({
330+
appId: "app_upload",
331+
appSecret: "secret_upload", // pragma: allowlist secret
332+
accountId: "multipart-upload",
333+
});
334+
335+
const httpInstance = readLastClientHttpInstance();
336+
337+
await httpInstance.request({
338+
url: "https://open.feishu.cn/open-apis/im/v1/files",
339+
method: "POST",
340+
headers: { "Content-Type": "multipart/form-data", Authorization: "Bearer token" },
341+
data: {
342+
file_type: "stream",
343+
file_name: "tiny.txt",
344+
file: Buffer.from("tiny"),
345+
},
346+
});
347+
348+
const delegated = readCallOptions(mockBaseHttpInstance.request);
349+
expect(delegated.timeout).toBe(FEISHU_HTTP_TIMEOUT_MS);
350+
expect(delegated.headers).toEqual({
351+
"Content-Type": "multipart/form-data",
352+
Authorization: "Bearer token",
353+
});
354+
expect(delegated.data).toBeInstanceOf(FormData);
355+
expect(delegated.data).not.toEqual({
356+
file_type: "stream",
357+
file_name: "tiny.txt",
358+
file: Buffer.from("tiny"),
359+
});
360+
});
361+
328362
it("uses config-configured default timeout when provided", async () => {
329363
createFeishuClient({
330364
appId: "app_4",

extensions/feishu/src/client.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,90 @@ type FeishuHttpInstanceLike = Pick<
8585
"request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
8686
>;
8787

88+
function isRecord(value: unknown): value is Record<string, unknown> {
89+
return typeof value === "object" && value !== null && !Array.isArray(value);
90+
}
91+
92+
function readHeader(headers: unknown, name: string): string | undefined {
93+
if (!isRecord(headers)) {
94+
return undefined;
95+
}
96+
const normalizedName = name.toLowerCase();
97+
for (const [key, value] of Object.entries(headers)) {
98+
if (key.toLowerCase() !== normalizedName) {
99+
continue;
100+
}
101+
if (typeof value === "string") {
102+
return value;
103+
}
104+
if (Array.isArray(value)) {
105+
const first = value.find((entry) => typeof entry === "string");
106+
return typeof first === "string" ? first : undefined;
107+
}
108+
}
109+
return undefined;
110+
}
111+
112+
function isMultipartFormRequest(opts: Lark.HttpRequestOptions<unknown>): boolean {
113+
return /^multipart\/form-data(?:;|$)/i.test(readHeader(opts.headers, "content-type") ?? "");
114+
}
115+
116+
function hasFeishuUploadPart(data: Record<string, unknown>): boolean {
117+
return Buffer.isBuffer(data.file) || Buffer.isBuffer(data.image);
118+
}
119+
120+
function stringifyMultipartFieldValue(value: unknown): string | undefined {
121+
switch (typeof value) {
122+
case "string":
123+
return value;
124+
case "number":
125+
case "boolean":
126+
case "bigint":
127+
return String(value);
128+
default:
129+
return undefined;
130+
}
131+
}
132+
133+
function bufferToBlobPart(value: Buffer): Uint8Array<ArrayBuffer> {
134+
const bytes = new Uint8Array(value.byteLength);
135+
bytes.set(value);
136+
return bytes;
137+
}
138+
139+
function normalizeMultipartUploadData<D>(
140+
opts: Lark.HttpRequestOptions<D>,
141+
): Lark.HttpRequestOptions<D> {
142+
if (!isMultipartFormRequest(opts) || !isRecord(opts.data) || !hasFeishuUploadPart(opts.data)) {
143+
return opts;
144+
}
145+
146+
const form = new FormData();
147+
const fileName =
148+
typeof opts.data.file_name === "string" && opts.data.file_name
149+
? opts.data.file_name
150+
: undefined;
151+
for (const [key, value] of Object.entries(opts.data)) {
152+
if (value === undefined || value === null) {
153+
continue;
154+
}
155+
if (Buffer.isBuffer(value)) {
156+
form.append(
157+
key,
158+
new Blob([bufferToBlobPart(value)]),
159+
key === "file" && fileName ? fileName : `${key}.bin`,
160+
);
161+
continue;
162+
}
163+
const fieldValue = stringifyMultipartFieldValue(value);
164+
if (fieldValue !== undefined) {
165+
form.append(key, fieldValue);
166+
}
167+
}
168+
169+
return { ...opts, data: form as D };
170+
}
171+
88172
function isManagedProxyActive() {
89173
return process.env["OPENCLAW_PROXY_ACTIVE"] === "1";
90174
}
@@ -193,7 +277,8 @@ function createFeishuHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
193277
}
194278

195279
return {
196-
request: async (opts) => base.request(await injectRequestOptions(opts)),
280+
request: async (opts) =>
281+
base.request(await injectRequestOptions(normalizeMultipartUploadData(opts))),
197282
get: async (url, opts) => base.get(url, await injectRequestOptions(opts)),
198283
post: async (url, data, opts) => base.post(url, data, await injectRequestOptions(opts)),
199284
put: async (url, data, opts) => base.put(url, data, await injectRequestOptions(opts)),

0 commit comments

Comments
 (0)