mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 02:15:02 +00:00
fix(feishu): chunk large documents for write/append to avoid API 400 errors (#14402)
* fix(feishu): chunk large documents for write/append to avoid API 400 errors The Feishu API limits documentBlockChildren.create to 50 blocks per request and document.convert has content size limits for large markdown. Previously, writeDoc and appendDoc would send the entire content in a single API call, causing HTTP 400 errors for long documents. This commit adds: - splitMarkdownByHeadings(): splits markdown at # or ## headings - chunkedConvertMarkdown(): converts each chunk independently - chunkedInsertBlocks(): batches blocks into groups of ≤50 Both writeDoc and appendDoc now use the chunked helpers while preserving backward compatibility for small documents. Image processing correctly receives all inserted blocks across batches. * fix(feishu): skip heading detection inside fenced code blocks Addresses review feedback: splitMarkdownByHeadings() now tracks fenced code blocks (``` or ~~~) and skips heading-based splitting when inside one, preventing corruption of code block content. * Feishu/Docx: add convert fallback chunking + tests --------- Co-authored-by: lml2468 <lml2468@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -147,7 +147,7 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
const result = await feishuDocTool.execute("tool-call", {
|
||||
action: "append",
|
||||
doc_token: "doc_1",
|
||||
content: "## H1\ntext\n## H2",
|
||||
content: "plain text body",
|
||||
});
|
||||
|
||||
// Verify sequential insertion: one call per block
|
||||
@@ -163,6 +163,135 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
expect(result.details.blocks_added).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to size-based convert chunking for long no-heading markdown", async () => {
|
||||
let successChunkCount = 0;
|
||||
convertMock.mockImplementation(async ({ data }) => {
|
||||
const content = data.content as string;
|
||||
if (content.length > 280) {
|
||||
return { code: 999, msg: "content too large" };
|
||||
}
|
||||
successChunkCount++;
|
||||
const blockId = `b_${successChunkCount}`;
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
blocks: [{ block_type: 2, block_id: blockId }],
|
||||
first_level_block_ids: [blockId],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
blockChildrenCreateMock.mockImplementation(async ({ data }) => ({
|
||||
code: 0,
|
||||
data: { children: data.children },
|
||||
}));
|
||||
|
||||
const registerTool = vi.fn();
|
||||
registerFeishuDocTools({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: { appId: "app_id", appSecret: "app_secret" },
|
||||
},
|
||||
} as any,
|
||||
logger: { debug: vi.fn(), info: vi.fn() } as any,
|
||||
registerTool,
|
||||
} as any);
|
||||
|
||||
const feishuDocTool = registerTool.mock.calls
|
||||
.map((call) => call[0])
|
||||
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
|
||||
.find((tool) => tool.name === "feishu_doc");
|
||||
expect(feishuDocTool).toBeDefined();
|
||||
|
||||
const longMarkdown = Array.from(
|
||||
{ length: 120 },
|
||||
(_, i) => `line ${i} with enough content to trigger fallback chunking`,
|
||||
).join("\n");
|
||||
|
||||
const result = await feishuDocTool.execute("tool-call", {
|
||||
action: "append",
|
||||
doc_token: "doc_1",
|
||||
content: longMarkdown,
|
||||
});
|
||||
|
||||
expect(convertMock.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(successChunkCount).toBeGreaterThan(1);
|
||||
expect(result.details.blocks_added).toBe(successChunkCount);
|
||||
});
|
||||
|
||||
it("keeps fenced code blocks balanced when size fallback split is needed", async () => {
|
||||
const convertedChunks: string[] = [];
|
||||
let successChunkCount = 0;
|
||||
let failFirstConvert = true;
|
||||
convertMock.mockImplementation(async ({ data }) => {
|
||||
const content = data.content as string;
|
||||
convertedChunks.push(content);
|
||||
if (failFirstConvert) {
|
||||
failFirstConvert = false;
|
||||
return { code: 999, msg: "content too large" };
|
||||
}
|
||||
successChunkCount++;
|
||||
const blockId = `c_${successChunkCount}`;
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
blocks: [{ block_type: 2, block_id: blockId }],
|
||||
first_level_block_ids: [blockId],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
blockChildrenCreateMock.mockImplementation(async ({ data }) => ({
|
||||
code: 0,
|
||||
data: { children: data.children },
|
||||
}));
|
||||
|
||||
const registerTool = vi.fn();
|
||||
registerFeishuDocTools({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: { appId: "app_id", appSecret: "app_secret" },
|
||||
},
|
||||
} as any,
|
||||
logger: { debug: vi.fn(), info: vi.fn() } as any,
|
||||
registerTool,
|
||||
} as any);
|
||||
|
||||
const feishuDocTool = registerTool.mock.calls
|
||||
.map((call) => call[0])
|
||||
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
|
||||
.find((tool) => tool.name === "feishu_doc");
|
||||
expect(feishuDocTool).toBeDefined();
|
||||
|
||||
const fencedMarkdown = [
|
||||
"## Section",
|
||||
"```ts",
|
||||
"const alpha = 1;",
|
||||
"const beta = 2;",
|
||||
"const gamma = alpha + beta;",
|
||||
"console.log(gamma);",
|
||||
"```",
|
||||
"",
|
||||
"Tail paragraph one with enough text to exceed API limits when combined. ".repeat(8),
|
||||
"Tail paragraph two with enough text to exceed API limits when combined. ".repeat(8),
|
||||
"Tail paragraph three with enough text to exceed API limits when combined. ".repeat(8),
|
||||
].join("\n");
|
||||
|
||||
const result = await feishuDocTool.execute("tool-call", {
|
||||
action: "append",
|
||||
doc_token: "doc_1",
|
||||
content: fencedMarkdown,
|
||||
});
|
||||
|
||||
expect(convertMock.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(successChunkCount).toBeGreaterThan(1);
|
||||
for (const chunk of convertedChunks) {
|
||||
const fenceCount = chunk.match(/```/g)?.length ?? 0;
|
||||
expect(fenceCount % 2).toBe(0);
|
||||
}
|
||||
expect(result.details.blocks_added).toBe(successChunkCount);
|
||||
});
|
||||
|
||||
it("skips image upload when markdown image URL is blocked", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
fetchRemoteMediaMock.mockRejectedValueOnce(
|
||||
|
||||
Reference in New Issue
Block a user