mirror of https://github.com/openclaw/openclaw.git
fix(feishu): preserve docx block tree order (openclaw#40524)
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm vitest run extensions/feishu/src/docx.test.ts Co-authored-by: Tao Xie <7379039+TaoXieSZ@users.noreply.github.com>
This commit is contained in:
parent
9082795b10
commit
0b54b64fe7
|
|
@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
### Fixes
|
||||
|
||||
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
|
||||
- Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0.
|
||||
|
||||
## 2026.3.23
|
||||
|
|
|
|||
|
|
@ -174,6 +174,50 @@ describe("feishu_doc image fetch hardening", () => {
|
|||
expect(result.details.blocks_added).toBe(3);
|
||||
});
|
||||
|
||||
it("reorders convert output by document tree instead of raw block array order", async () => {
|
||||
const blocks = [
|
||||
{ block_type: 13, block_id: "li2", parent_id: "list1" },
|
||||
{ block_type: 4, block_id: "h2" },
|
||||
{ block_type: 13, block_id: "li1", parent_id: "list1" },
|
||||
{ block_type: 3, block_id: "h1" },
|
||||
{ block_type: 12, block_id: "list1", children: ["li1", "li2"] },
|
||||
{ block_type: 2, block_id: "p1" },
|
||||
];
|
||||
convertMock.mockResolvedValue({
|
||||
code: 0,
|
||||
data: {
|
||||
blocks,
|
||||
first_level_block_ids: ["h1", "p1", "h2", "list1"],
|
||||
},
|
||||
});
|
||||
|
||||
blockDescendantCreateMock.mockImplementationOnce(async ({ data }) => ({
|
||||
code: 0,
|
||||
data: {
|
||||
children: (data.children_id as string[]).map((id) => ({ block_id: id })),
|
||||
},
|
||||
}));
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool();
|
||||
|
||||
await feishuDocTool.execute("tool-call", {
|
||||
action: "append",
|
||||
doc_token: "doc_1",
|
||||
content: "tree reorder",
|
||||
});
|
||||
|
||||
const call = blockDescendantCreateMock.mock.calls[0]?.[0];
|
||||
expect(call?.data.children_id).toEqual(["h1", "p1", "h2", "list1"]);
|
||||
expect((call?.data.descendants as Array<{ block_id: string }>).map((b) => b.block_id)).toEqual([
|
||||
"h1",
|
||||
"p1",
|
||||
"h2",
|
||||
"list1",
|
||||
"li1",
|
||||
"li2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to size-based convert chunking for long no-heading markdown", async () => {
|
||||
let successChunkCount = 0;
|
||||
convertMock.mockImplementation(async ({ data }) => {
|
||||
|
|
|
|||
|
|
@ -113,12 +113,90 @@ async function convertMarkdown(client: Lark.Client, markdown: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
|
||||
if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
|
||||
const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
|
||||
const sortedIds = new Set(firstLevelIds);
|
||||
const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
|
||||
return [...sorted, ...remaining];
|
||||
function normalizeChildIds(children: unknown): string[] {
|
||||
if (Array.isArray(children)) {
|
||||
return children.filter((child): child is string => typeof child === "string");
|
||||
}
|
||||
if (typeof children === "string") {
|
||||
return [children];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert API may return `blocks` in a non-render order.
|
||||
// Reconstruct the document tree using first_level_block_ids plus children/parent links,
|
||||
// then emit blocks in pre-order so Descendant/Children APIs receive one normalized tree contract.
|
||||
function normalizeConvertedBlockTree(
|
||||
blocks: any[],
|
||||
firstLevelIds: string[],
|
||||
): { orderedBlocks: any[]; rootIds: string[] } {
|
||||
if (blocks.length <= 1) {
|
||||
const rootIds =
|
||||
blocks.length === 1 && typeof blocks[0]?.block_id === "string" ? [blocks[0].block_id] : [];
|
||||
return { orderedBlocks: blocks, rootIds };
|
||||
}
|
||||
|
||||
const byId = new Map<string, any>();
|
||||
const originalOrder = new Map<string, number>();
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
if (typeof block?.block_id === "string") {
|
||||
byId.set(block.block_id, block);
|
||||
originalOrder.set(block.block_id, index);
|
||||
}
|
||||
}
|
||||
|
||||
const childIds = new Set<string>();
|
||||
for (const block of blocks) {
|
||||
for (const childId of normalizeChildIds(block?.children)) {
|
||||
childIds.add(childId);
|
||||
}
|
||||
}
|
||||
|
||||
const inferredTopLevelIds = blocks
|
||||
.filter((block) => {
|
||||
const blockId = block?.block_id;
|
||||
if (typeof blockId !== "string") {
|
||||
return false;
|
||||
}
|
||||
const parentId = typeof block?.parent_id === "string" ? block.parent_id : "";
|
||||
return !childIds.has(blockId) && (!parentId || !byId.has(parentId));
|
||||
})
|
||||
.sort((a, b) => (originalOrder.get(a.block_id) ?? 0) - (originalOrder.get(b.block_id) ?? 0))
|
||||
.map((block) => block.block_id);
|
||||
|
||||
const rootIds = (
|
||||
firstLevelIds && firstLevelIds.length > 0 ? firstLevelIds : inferredTopLevelIds
|
||||
).filter((id, index, arr) => typeof id === "string" && byId.has(id) && arr.indexOf(id) === index);
|
||||
|
||||
const orderedBlocks: any[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const visit = (blockId: string) => {
|
||||
if (!byId.has(blockId) || visited.has(blockId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(blockId);
|
||||
const block = byId.get(blockId);
|
||||
orderedBlocks.push(block);
|
||||
for (const childId of normalizeChildIds(block?.children)) {
|
||||
visit(childId);
|
||||
}
|
||||
};
|
||||
|
||||
for (const rootId of rootIds) {
|
||||
visit(rootId);
|
||||
}
|
||||
|
||||
// Fallback for malformed/partial trees from Convert API: keep any leftovers in original order.
|
||||
for (const block of blocks) {
|
||||
if (typeof block?.block_id === "string") {
|
||||
visit(block.block_id);
|
||||
} else {
|
||||
orderedBlocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
return { orderedBlocks, rootIds };
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
|
||||
|
|
@ -259,14 +337,14 @@ async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) {
|
|||
const chunks = splitMarkdownByHeadings(markdown);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
|
||||
const allBlocks: any[] = [];
|
||||
const allFirstLevelBlockIds: string[] = [];
|
||||
const allRootIds: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk);
|
||||
const sorted = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
|
||||
allBlocks.push(...sorted);
|
||||
allFirstLevelBlockIds.push(...firstLevelBlockIds);
|
||||
const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
|
||||
allBlocks.push(...orderedBlocks);
|
||||
allRootIds.push(...rootIds);
|
||||
}
|
||||
return { blocks: allBlocks, firstLevelBlockIds: allFirstLevelBlockIds };
|
||||
return { blocks: allBlocks, firstLevelBlockIds: allRootIds };
|
||||
}
|
||||
|
||||
/** Insert blocks in batches of MAX_BLOCKS_PER_INSERT to avoid API 400 errors */
|
||||
|
|
@ -644,8 +722,11 @@ async function uploadFileBlock(
|
|||
// Create a placeholder text block first
|
||||
const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`;
|
||||
const converted = await convertMarkdown(client, placeholderMd);
|
||||
const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds);
|
||||
const { children: inserted } = await insertBlocks(client, docToken, sorted, blockId);
|
||||
const { orderedBlocks } = normalizeConvertedBlockTree(
|
||||
converted.blocks,
|
||||
converted.firstLevelBlockIds,
|
||||
);
|
||||
const { children: inserted } = await insertBlocks(client, docToken, orderedBlocks, blockId);
|
||||
|
||||
// Get the first inserted block - we'll delete it and create the file in its place
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape
|
||||
|
|
@ -824,11 +905,11 @@ async function writeDoc(
|
|||
}
|
||||
|
||||
logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
|
||||
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
|
||||
const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
|
||||
const { children: inserted } =
|
||||
blocks.length > BATCH_SIZE
|
||||
? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger)
|
||||
: await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds);
|
||||
? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger)
|
||||
: await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds);
|
||||
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
|
||||
logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
|
||||
|
||||
|
|
@ -854,11 +935,11 @@ async function appendDoc(
|
|||
}
|
||||
|
||||
logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
|
||||
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
|
||||
const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
|
||||
const { children: inserted } =
|
||||
blocks.length > BATCH_SIZE
|
||||
? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger)
|
||||
: await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds);
|
||||
? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger)
|
||||
: await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds);
|
||||
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
|
||||
logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
|
||||
|
||||
|
|
@ -914,7 +995,7 @@ async function insertDoc(
|
|||
logger?.info?.("feishu_doc: Converting markdown...");
|
||||
const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
|
||||
if (blocks.length === 0) throw new Error("Content is empty");
|
||||
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
|
||||
const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
|
||||
|
||||
logger?.info?.(
|
||||
`feishu_doc: Converted to ${blocks.length} blocks, inserting at index ${insertIndex}...`,
|
||||
|
|
@ -924,13 +1005,13 @@ async function insertDoc(
|
|||
? await insertBlocksInBatches(
|
||||
client,
|
||||
docToken,
|
||||
sortedBlocks,
|
||||
firstLevelBlockIds,
|
||||
orderedBlocks,
|
||||
rootIds,
|
||||
logger,
|
||||
parentId,
|
||||
insertIndex,
|
||||
)
|
||||
: await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds, {
|
||||
: await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds, {
|
||||
parentBlockId: parentId,
|
||||
index: insertIndex,
|
||||
});
|
||||
|
|
@ -1067,10 +1148,13 @@ async function writeTableCells(
|
|||
|
||||
const text = rowValues[c] ?? "";
|
||||
const converted = await convertMarkdown(client, text);
|
||||
const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds);
|
||||
const { orderedBlocks } = normalizeConvertedBlockTree(
|
||||
converted.blocks,
|
||||
converted.firstLevelBlockIds,
|
||||
);
|
||||
|
||||
if (sorted.length > 0) {
|
||||
await insertBlocks(client, docToken, sorted, cellId);
|
||||
if (orderedBlocks.length > 0) {
|
||||
await insertBlocks(client, docToken, orderedBlocks, cellId);
|
||||
}
|
||||
|
||||
written++;
|
||||
|
|
|
|||
Loading…
Reference in New Issue