Skip to content

Commit b28344e

Browse files
青雲echoVicTakhoffman
authored
fix(feishu): insert document blocks sequentially to preserve order (#26022) (#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 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 83698bf commit b28344e

File tree

3 files changed

+77
-10
lines changed

3 files changed

+77
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- 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.
3131
- 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.
3232
- 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.
33+
- 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.
3334
- 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.
3435
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
3536
- 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.

extensions/feishu/src/docx.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,64 @@ describe("feishu_doc image fetch hardening", () => {
105105
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
106106
});
107107

108+
it("inserts blocks sequentially to preserve document order", async () => {
109+
const blocks = [
110+
{ block_type: 3, block_id: "h1" },
111+
{ block_type: 2, block_id: "t1" },
112+
{ block_type: 3, block_id: "h2" },
113+
];
114+
convertMock.mockResolvedValue({
115+
code: 0,
116+
data: {
117+
blocks,
118+
first_level_block_ids: ["h1", "t1", "h2"],
119+
},
120+
});
121+
122+
blockListMock.mockResolvedValue({ code: 0, data: { items: [] } });
123+
124+
// Each call returns the single block that was passed in
125+
blockChildrenCreateMock
126+
.mockResolvedValueOnce({ code: 0, data: { children: [{ block_type: 3, block_id: "h1" }] } })
127+
.mockResolvedValueOnce({ code: 0, data: { children: [{ block_type: 2, block_id: "t1" }] } })
128+
.mockResolvedValueOnce({ code: 0, data: { children: [{ block_type: 3, block_id: "h2" }] } });
129+
130+
const registerTool = vi.fn();
131+
registerFeishuDocTools({
132+
config: {
133+
channels: {
134+
feishu: { appId: "app_id", appSecret: "app_secret" },
135+
},
136+
} as any,
137+
logger: { debug: vi.fn(), info: vi.fn() } as any,
138+
registerTool,
139+
} as any);
140+
141+
const feishuDocTool = registerTool.mock.calls
142+
.map((call) => call[0])
143+
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
144+
.find((tool) => tool.name === "feishu_doc");
145+
expect(feishuDocTool).toBeDefined();
146+
147+
const result = await feishuDocTool.execute("tool-call", {
148+
action: "append",
149+
doc_token: "doc_1",
150+
content: "## H1\ntext\n## H2",
151+
});
152+
153+
// Verify sequential insertion: one call per block
154+
expect(blockChildrenCreateMock).toHaveBeenCalledTimes(3);
155+
156+
// Verify each call received exactly one block in the correct order
157+
const calls = blockChildrenCreateMock.mock.calls;
158+
expect(calls[0][0].data.children).toHaveLength(1);
159+
expect(calls[0][0].data.children[0].block_id).toBe("h1");
160+
expect(calls[1][0].data.children[0].block_id).toBe("t1");
161+
expect(calls[2][0].data.children[0].block_id).toBe("h2");
162+
163+
expect(result.details.blocks_added).toBe(3);
164+
});
165+
108166
it("skips image upload when markdown image URL is blocked", async () => {
109167
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
110168
fetchRemoteMediaMock.mockRejectedValueOnce(

extensions/feishu/src/docx.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,25 @@ async function insertBlocks(
122122
return { children: [], skipped };
123123
}
124124

125-
const res = await client.docx.documentBlockChildren.create({
126-
path: { document_id: docToken, block_id: blockId },
127-
data: {
128-
children: cleaned,
129-
...(index !== undefined && { index }),
130-
},
131-
});
132-
if (res.code !== 0) {
133-
throw new Error(res.msg);
125+
// Insert blocks one at a time to preserve document order.
126+
// The batch API (sending all children at once) does not guarantee ordering
127+
// because Feishu processes the batch asynchronously. Sequential single-block
128+
// inserts (each appended to the end) produce deterministic results.
129+
const allInserted: any[] = [];
130+
for (const [offset, block] of cleaned.entries()) {
131+
const res = await client.docx.documentBlockChildren.create({
132+
path: { document_id: docToken, block_id: blockId },
133+
data: {
134+
children: [block],
135+
...(index !== undefined ? { index: index + offset } : {}),
136+
},
137+
});
138+
if (res.code !== 0) {
139+
throw new Error(res.msg);
140+
}
141+
allInserted.push(...(res.data?.children ?? []));
134142
}
135-
return { children: res.data?.children ?? [], skipped };
143+
return { children: allInserted, skipped };
136144
}
137145

138146
async function clearDocumentContent(client: Lark.Client, docToken: string) {

0 commit comments

Comments
 (0)