fix(feishu): insert document blocks sequentially to preserve order (#26022) (openclaw#26172) thanks @echoVic

Verified:
- pnpm build
- pnpm check
- pnpm vitest run --config vitest.extensions.config.ts extensions/feishu/src/docx.test.ts

Co-authored-by: echoVic <16428813+echoVic@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
青雲 2026-02-28 12:48:14 +08:00 committed by GitHub
parent 83698bf13e
commit b28344eacc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 77 additions and 10 deletions

View File

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

View File

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

View File

@ -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) {