mirror of https://github.com/openclaw/openclaw.git
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:
parent
83698bf13e
commit
b28344eacc
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue