diff --git a/CHANGELOG.md b/CHANGELOG.md index 74dd21972ee..056189e6b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng. - Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth. - Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups..allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild. +- Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic. - Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3. - Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc. - Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus. diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index 14e36e09c0a..740872a61b8 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -105,6 +105,64 @@ describe("feishu_doc image fetch hardening", () => { scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } }); }); + it("inserts blocks sequentially to preserve document order", async () => { + const blocks = [ + { block_type: 3, block_id: "h1" }, + { block_type: 2, block_id: "t1" }, + { block_type: 3, block_id: "h2" }, + ]; + convertMock.mockResolvedValue({ + code: 0, + data: { + blocks, + first_level_block_ids: ["h1", "t1", "h2"], + }, + }); + + blockListMock.mockResolvedValue({ code: 0, data: { items: [] } }); + + // Each call returns the single block that was passed in + blockChildrenCreateMock + .mockResolvedValueOnce({ code: 0, data: { children: [{ block_type: 3, block_id: "h1" }] } }) + .mockResolvedValueOnce({ code: 0, data: { children: [{ block_type: 2, block_id: "t1" }] } }) + .mockResolvedValueOnce({ code: 0, data: { children: [{ block_type: 3, block_id: "h2" }] } }); + + 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 result = await feishuDocTool.execute("tool-call", { + action: "append", + doc_token: "doc_1", + content: "## H1\ntext\n## H2", + }); + + // Verify sequential insertion: one call per block + expect(blockChildrenCreateMock).toHaveBeenCalledTimes(3); + + // Verify each call received exactly one block in the correct order + const calls = blockChildrenCreateMock.mock.calls; + expect(calls[0][0].data.children).toHaveLength(1); + expect(calls[0][0].data.children[0].block_id).toBe("h1"); + expect(calls[1][0].data.children[0].block_id).toBe("t1"); + expect(calls[2][0].data.children[0].block_id).toBe("h2"); + + expect(result.details.blocks_added).toBe(3); + }); + it("skips image upload when markdown image URL is blocked", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); fetchRemoteMediaMock.mockRejectedValueOnce( diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index a934f1d3aa8..8d3385aa4c1 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -122,17 +122,25 @@ async function insertBlocks( return { children: [], skipped }; } - const res = await client.docx.documentBlockChildren.create({ - path: { document_id: docToken, block_id: blockId }, - data: { - children: cleaned, - ...(index !== undefined && { index }), - }, - }); - if (res.code !== 0) { - throw new Error(res.msg); + // Insert blocks one at a time to preserve document order. + // The batch API (sending all children at once) does not guarantee ordering + // because Feishu processes the batch asynchronously. Sequential single-block + // inserts (each appended to the end) produce deterministic results. + const allInserted: any[] = []; + for (const [offset, block] of cleaned.entries()) { + const res = await client.docx.documentBlockChildren.create({ + path: { document_id: docToken, block_id: blockId }, + data: { + children: [block], + ...(index !== undefined ? { index: index + offset } : {}), + }, + }); + if (res.code !== 0) { + throw new Error(res.msg); + } + allInserted.push(...(res.data?.children ?? [])); } - return { children: res.data?.children ?? [], skipped }; + return { children: allInserted, skipped }; } async function clearDocumentContent(client: Lark.Client, docToken: string) {