diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c43a33b7c93..ddfd04288009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Feishu document image reads:** bound remote document-image headers and stalled bodies with the selected account timeout, parse document Markdown through the plugin's MDAST pipeline, preserve image/block alignment, and reject failed upload input before creating empty image blocks. Thanks @Alix-007. - **ClawHub registry reads:** retry bounded HTTP 500 responses alongside other transient gateway failures so multi-package release scans survive isolated registry errors. - **Slack Socket Mode health:** report connected Socket Mode transports as degraded when `auth.test` fails or the configured bot token resolves to a user without `bot_id`, while preserving healthy enterprise-org installs. Thanks @zw-xysk. - **Synology Chat response limits:** bound user-list response reads, stop oversized streams immediately, and retain stale cached identities when a NAS exceeds the supported envelope. Thanks @zw-xysk. diff --git a/extensions/feishu/src/docx-markdown.test.ts b/extensions/feishu/src/docx-markdown.test.ts new file mode 100644 index 000000000000..f54309851aff --- /dev/null +++ b/extensions/feishu/src/docx-markdown.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { createDocxMarkdownPlan, splitDocxMarkdownBySize } from "./docx-markdown.js"; +import { parseFeishuMarkdown } from "./markdown.js"; + +describe("Feishu document Markdown planning", () => { + it("collects parsed remote images without treating code as image syntax", () => { + const markdown = [ + "![local](data:image/png;base64,AAAA)", + "", + "```md", + "![not-an-image](https://fake.test/code.png)", + "```", + "", + '![inline](https://cdn.test/image_(1).png "title")', + "![referenced][hero]", + "", + '[hero]: https://cdn.test/hero.png "Hero"', + ].join("\n"); + + expect(createDocxMarkdownPlan(markdown).chunks.flatMap((chunk) => chunk.images)).toEqual([ + { url: undefined }, + { url: "https://cdn.test/image_(1).png" }, + { url: "https://cdn.test/hero.png" }, + ]); + }); + + it("splits at parsed level-one and level-two headings while preserving source", () => { + const markdown = [ + "Intro", + "---", + "", + "```md", + "## not a heading", + "```", + "", + "# Section", + "body", + "", + "## Next", + "tail", + ].join("\n"); + + const chunks = createDocxMarkdownPlan(markdown).chunks.map((chunk) => chunk.markdown); + + expect(chunks).toHaveLength(3); + expect(chunks[0]).toContain("## not a heading"); + expect(chunks[1]).toBe("# Section\nbody\n\n"); + expect(chunks[2]).toBe("## Next\ntail"); + expect(chunks.join("")).toBe(markdown); + }); + + it("resolves image references only within their independently converted chunk", () => { + const markdown = [ + "![cross-chunk][hero]", + "", + "## Next", + "![remote](https://cdn.test/remote.png)", + "", + "[hero]: https://cdn.test/hero.png", + ].join("\n"); + + const { chunks } = createDocxMarkdownPlan(markdown); + + expect(chunks.map((chunk) => chunk.images)).toEqual([ + [], + [{ url: "https://cdn.test/remote.png" }], + ]); + }); + + it("uses parsed block and plain-text boundaries for size fallback", () => { + const markdown = `${"alpha ".repeat(80)}\n\n${"beta ".repeat(80)}`; + + const chunks = splitDocxMarkdownBySize(markdown, 240); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.join("")).toBe(markdown); + }); + + it("does not promote an inline block marker when splitting a long paragraph", () => { + const markdown = `${"alpha ".repeat(50)}# literal heading marker ${"omega ".repeat(50)}`; + + const chunks = splitDocxMarkdownBySize(markdown, "alpha ".repeat(50).length); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.join("")).toBe(markdown); + for (const chunk of chunks) { + expect(parseFeishuMarkdown(chunk).children?.[0]?.type).toBe("paragraph"); + } + }); + + it("keeps an indivisible fenced block parseable when fallback must split it", () => { + const markdown = [ + "```ts", + ...Array.from({ length: 80 }, (_, index) => `line_${index}();`), + "```", + ].join("\n"); + + const chunks = splitDocxMarkdownBySize(markdown, 160); + + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(parseFeishuMarkdown(chunk).children?.some((node) => node.type === "code")).toBe(true); + } + }); + + it("splits indented code only where both chunks remain code", () => { + const markdown = Array.from({ length: 20 }, (_, index) => ` line_${index}();`).join("\n"); + + const chunks = splitDocxMarkdownBySize(markdown, 80); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.join("")).toBe(markdown); + for (const chunk of chunks) { + expect(parseFeishuMarkdown(chunk).children?.[0]?.type).toBe("code"); + } + }); + + it.each([ + { + name: "list", + markdown: Array.from({ length: 20 }, (_, index) => `- item ${index}`).join("\n"), + nodeType: "list", + }, + { + name: "blockquote", + markdown: Array.from({ length: 20 }, (_, index) => `> quote ${index}`).join("\n"), + nodeType: "blockquote", + }, + ])("splits a single long $name only at stable container boundaries", ({ markdown, nodeType }) => { + const chunks = splitDocxMarkdownBySize(markdown, 80); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.join("")).toBe(markdown); + for (const chunk of chunks) { + expect(parseFeishuMarkdown(chunk).children?.[0]?.type).toBe(nodeType); + } + }); + + it("splits a GFM table between rows and repeats its parsed header", () => { + const header = "| Name | Value |\n| --- | --- |\n"; + const markdown = `${header}${Array.from( + { length: 20 }, + (_, index) => `| item ${index} | value ${index} |`, + ).join("\n")}`; + + const chunks = splitDocxMarkdownBySize(markdown, 180); + + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.startsWith(header)).toBe(true); + expect(parseFeishuMarkdown(chunk).children?.[0]?.type).toBe("table"); + } + }); +}); diff --git a/extensions/feishu/src/docx-markdown.ts b/extensions/feishu/src/docx-markdown.ts new file mode 100644 index 000000000000..4ec553759d80 --- /dev/null +++ b/extensions/feishu/src/docx-markdown.ts @@ -0,0 +1,291 @@ +import { chunkFeishuMarkdown, parseFeishuMarkdown, type FeishuMarkdownNode } from "./markdown.js"; + +export type DocxMarkdownImage = { url: string | undefined }; + +export type DocxMarkdownChunk = { + markdown: string; + images: DocxMarkdownImage[]; +}; + +type DocxMarkdownPlan = { + chunks: DocxMarkdownChunk[]; +}; + +const MAX_BREAK_PROBES = 32; +const STABLE_LINE_CONTAINER_TYPES = new Set(["list", "blockquote", "code"]); + +function visitMarkdown(root: FeishuMarkdownNode, visitor: (node: FeishuMarkdownNode) => void) { + const pending = [root]; + while (pending.length > 0) { + const node = pending.pop(); + if (!node) { + continue; + } + visitor(node); + if (!node.children) { + continue; + } + for (let index = node.children.length - 1; index >= 0; index -= 1) { + const child = node.children[index]; + if (child) { + pending.push(child); + } + } + } +} + +function resolveRemoteImageUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? value : undefined; + } catch { + return undefined; + } +} + +function collectMarkdownImages(root: FeishuMarkdownNode): DocxMarkdownImage[] { + const definitions = new Map(); + visitMarkdown(root, (node) => { + if (node.type !== "definition" || !node.identifier || !node.url) { + return; + } + // CommonMark resolves the first matching definition. + if (!definitions.has(node.identifier)) { + definitions.set(node.identifier, node.url); + } + }); + + const images: DocxMarkdownImage[] = []; + visitMarkdown(root, (node) => { + if (node.type === "image") { + images.push({ url: resolveRemoteImageUrl(node.url) }); + return; + } + if (node.type === "imageReference") { + images.push({ + url: resolveRemoteImageUrl(node.identifier ? definitions.get(node.identifier) : undefined), + }); + } + }); + return images; +} + +function splitSourceAtOffsets(source: string, offsets: number[]): string[] { + const chunks: string[] = []; + let start = 0; + for (const offset of offsets) { + if (offset <= start || offset >= source.length) { + continue; + } + chunks.push(source.slice(start, offset)); + start = offset; + } + chunks.push(source.slice(start)); + return chunks; +} + +function headingOffsets(source: string, root: FeishuMarkdownNode): number[] { + const offsets: number[] = []; + for (const node of root.children ?? []) { + if (node.type !== "heading" || node.depth === undefined || node.depth > 2) { + continue; + } + const offset = node.position?.start.offset; + // Preserve the existing ATX-only chunking contract; setext headings were + // never request boundaries and may depend on nearby reference definitions. + if (offset !== undefined && source[offset] === "#") { + offsets.push(offset); + } + } + return offsets; +} + +function blockBreakOffsets(root: FeishuMarkdownNode): number[] { + const offsets: number[] = []; + for (const block of root.children ?? []) { + const blockStart = block.position?.start.offset; + if (blockStart !== undefined && blockStart > 0) { + offsets.push(blockStart); + } + } + return offsets; +} + +function paragraphBreakOffsets(source: string, root: FeishuMarkdownNode): number[] { + const offsets: number[] = []; + for (const block of root.children ?? []) { + if (block.type !== "paragraph") { + continue; + } + for (const child of block.children ?? []) { + if (child.type !== "text") { + continue; + } + const start = child.position?.start.offset; + const end = child.position?.end.offset; + if (start === undefined || end === undefined) { + continue; + } + for (let offset = start; offset < end; offset += 1) { + const char = source[offset]; + if (char === " " || char === "\t" || char === "\n" || char === "\r") { + offsets.push(offset + 1); + } + } + } + } + return offsets; +} + +function lineBreakOffsets(source: string): number[] { + const offsets: number[] = []; + for (let offset = 0; offset < source.length; offset += 1) { + if (source[offset] === "\n" && offset + 1 < source.length) { + offsets.push(offset + 1); + } + } + return offsets; +} + +function nearestOffset(offsets: readonly number[], target: number, sourceLength: number) { + return offsets + .filter((offset) => offset > 0 && offset < sourceLength) + .toSorted((left, right) => Math.abs(left - target) - Math.abs(right - target) || left - right); +} + +function isStableParagraphBreak(source: string, offset: number): boolean { + const before = parseFeishuMarkdown(source.slice(0, offset)).children?.at(-1); + const after = parseFeishuMarkdown(source.slice(offset)).children?.[0]; + return before?.type === "paragraph" && after?.type === "paragraph"; +} + +function isStableContainerBreak(source: string, offset: number, containerType: string): boolean { + const before = parseFeishuMarkdown(source.slice(0, offset)).children; + const after = parseFeishuMarkdown(source.slice(offset)).children; + return ( + before?.length === 1 && + after?.length === 1 && + before[0]?.type === containerType && + after[0]?.type === containerType + ); +} + +function splitTableAtRow( + source: string, + root: FeishuMarkdownNode, + target: number, +): string[] | undefined { + const table = root.children?.length === 1 ? root.children[0] : undefined; + if (table?.type !== "table") { + return undefined; + } + const rows = table.children ?? []; + const firstBodyOffset = rows[1]?.position?.start.offset; + const splitOffset = nearestOffset( + rows + .slice(2) + .map((row) => row.position?.start.offset) + .filter((offset): offset is number => offset !== undefined), + target, + source.length, + )[0]; + if (firstBodyOffset === undefined || splitOffset === undefined) { + return undefined; + } + + const tableStart = table.position?.start.offset ?? 0; + const repeatedHeader = source.slice(tableStart, firstBodyOffset); + const chunks = [source.slice(0, splitOffset), `${repeatedHeader}${source.slice(splitOffset)}`]; + if (chunks.every((chunk) => parseFeishuMarkdown(chunk).children?.[0]?.type === "table")) { + return chunks; + } + return undefined; +} + +function isFencedCodeSource(source: string): boolean { + const firstLineEnd = source.indexOf("\n"); + const firstLine = source.slice(0, firstLineEnd === -1 ? source.length : firstLineEnd); + let indent = 0; + while (indent < firstLine.length && firstLine[indent] === " ") { + indent += 1; + } + if (indent > 3) { + return false; + } + const marker = firstLine.slice(indent); + return marker.startsWith("```") || marker.startsWith("~~~"); +} + +export function createDocxMarkdownChunk(markdown: string): DocxMarkdownChunk { + const root = parseFeishuMarkdown(markdown); + return { + markdown, + images: collectMarkdownImages(root), + }; +} + +export function createDocxMarkdownPlan(markdown: string): DocxMarkdownPlan { + const root = parseFeishuMarkdown(markdown); + return { + // Feishu converts each request independently. Parse each exact chunk again + // so cross-chunk reference definitions cannot shift later image block mapping. + chunks: splitSourceAtOffsets(markdown, headingOffsets(markdown, root)).map( + createDocxMarkdownChunk, + ), + }; +} + +export function splitDocxMarkdownBySize(markdown: string, maxChars: number): string[] { + if (markdown.length <= maxChars) { + return [markdown]; + } + + const root = parseFeishuMarkdown(markdown); + const target = Math.min(markdown.length - 1, Math.max(1, maxChars)); + const splitOffset = nearestOffset(blockBreakOffsets(root), target, markdown.length)[0]; + if (splitOffset !== undefined) { + return [markdown.slice(0, splitOffset), markdown.slice(splitOffset)]; + } + + // A direct text-node boundary is usable only when both independently parsed + // halves remain paragraphs; otherwise a literal marker can become a new block. + const paragraphOffset = nearestOffset( + paragraphBreakOffsets(markdown, root), + target, + markdown.length, + ) + // Keep reparsing bounded for hostile, whitespace-heavy input. + .slice(0, MAX_BREAK_PROBES) + .find((offset) => isStableParagraphBreak(markdown, offset)); + if (paragraphOffset !== undefined) { + return [markdown.slice(0, paragraphOffset), markdown.slice(paragraphOffset)]; + } + + // Fence balancing is semantics-preserving only for one fenced code node. + if ( + root.children?.length === 1 && + root.children[0]?.type === "code" && + isFencedCodeSource(markdown) + ) { + return chunkFeishuMarkdown(markdown, maxChars); + } + + const tableChunks = splitTableAtRow(markdown, root, target); + if (tableChunks) { + return tableChunks; + } + + const containerType = root.children?.length === 1 ? root.children[0]?.type : undefined; + if (containerType && STABLE_LINE_CONTAINER_TYPES.has(containerType)) { + const containerOffset = nearestOffset(lineBreakOffsets(markdown), target, markdown.length) + .slice(0, MAX_BREAK_PROBES) + .find((offset) => isStableContainerBreak(markdown, offset, containerType)); + if (containerOffset !== undefined) { + return [markdown.slice(0, containerOffset), markdown.slice(containerOffset)]; + } + } + return [markdown]; +} diff --git a/extensions/feishu/src/docx-upload-input.ts b/extensions/feishu/src/docx-upload-input.ts new file mode 100644 index 000000000000..2b5d9099600d --- /dev/null +++ b/extensions/feishu/src/docx-upload-input.ts @@ -0,0 +1,161 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, isAbsolute, resolve } from "node:path"; +import { + canonicalizeBase64, + estimateBase64DecodedBytes, + extensionForMime, +} from "openclaw/plugin-sdk/media-runtime"; +import { getFeishuRuntime } from "./runtime.js"; + +type DocxUploadInput = { + url?: string; + filePath?: string; + image?: string; + maxBytes: number; + localRoots?: readonly string[]; + fileName?: string; + remoteReadTimeoutMs?: number; +}; + +function decodeBase64Image(params: { + payload: string; + maxBytes: number; + invalidMessage: string; + oversizedLabel: string; +}): Buffer { + const estimatedBytes = estimateBase64DecodedBytes(params.payload); + if (estimatedBytes > params.maxBytes) { + throw new Error( + `${params.oversizedLabel} exceeds limit: estimated ${estimatedBytes} bytes > ${params.maxBytes} bytes`, + ); + } + const canonical = canonicalizeBase64(params.payload); + if (!canonical) { + throw new Error(params.invalidMessage); + } + return Buffer.from(canonical, "base64"); +} + +function resolveDataUriImage(image: string, maxBytes: number, fileName?: string) { + const commaIndex = image.indexOf(","); + if (commaIndex === -1) { + throw new Error("Invalid data URI: missing comma separator."); + } + const metadata = image.slice("data:".length, commaIndex).split(";"); + const encoding = metadata.slice(1).map((value) => value.trim().toLowerCase()); + if (!encoding.includes("base64")) { + throw new Error( + "Invalid data URI: missing ';base64' marker. Expected format: data:image/png;base64,", + ); + } + const buffer = decodeBase64Image({ + payload: image.slice(commaIndex + 1), + maxBytes, + invalidMessage: "Invalid data URI: base64 payload is malformed.", + oversizedLabel: "Image data URI", + }); + const mime = metadata[0]?.trim(); + const extension = extensionForMime(mime)?.slice(1) ?? "png"; + return { buffer, fileName: fileName ?? `image.${extension}` }; +} + +async function resolveLocalUpload( + filePath: string, + maxBytes: number, + localRoots: readonly string[] | undefined, + fileName: string | undefined, +) { + const loaded = await getFeishuRuntime().media.loadWebMedia(resolve(filePath), { + maxBytes, + optimizeImages: false, + localRoots, + }); + return { buffer: loaded.buffer, fileName: fileName ?? basename(filePath) }; +} + +function resolveImageLocalPath(image: string): string | undefined { + const candidate = image.startsWith("~") ? `${homedir()}${image.slice(1)}` : image; + const unambiguousPath = + image.startsWith("~") || image.startsWith("./") || image.startsWith("../"); + const absolutePath = isAbsolute(image); + + if (unambiguousPath || (absolutePath && existsSync(candidate))) { + return candidate; + } + if (absolutePath) { + throw new Error( + `File not found: "${candidate}". If you intended to pass image binary data, use a data URI instead: data:image/jpeg;base64,...`, + ); + } + return undefined; +} + +async function resolveRemoteUpload(input: DocxUploadInput & { url: string }) { + const fetched = await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ + url: input.url, + maxBytes: input.maxBytes, + ...(input.remoteReadTimeoutMs !== undefined + ? { + responseHeaderTimeoutMs: input.remoteReadTimeoutMs, + readIdleTimeoutMs: input.remoteReadTimeoutMs, + } + : {}), + }); + const urlPath = new URL(input.url).pathname; + const urlFileName = urlPath.split("/").pop() || "upload.bin"; + return { + buffer: fetched.buffer, + fileName: input.fileName ?? fetched.fileName ?? urlFileName, + }; +} + +export async function resolveDocxUploadInput( + input: DocxUploadInput, +): Promise<{ buffer: Buffer; fileName: string }> { + const sources = [ + input.url && "url", + input.filePath && "file_path", + input.image && "image", + ].filter((source): source is string => Boolean(source)); + if (sources.length !== 1) { + throw new Error( + sources.length === 0 + ? "Either url, file_path, or image (base64/data URI) must be provided" + : `Provide only one upload source; got: ${sources.join(", ")}`, + ); + } + + if (input.url) { + return await resolveRemoteUpload({ ...input, url: input.url }); + } + if (input.filePath) { + return await resolveLocalUpload( + input.filePath, + input.maxBytes, + input.localRoots, + input.fileName, + ); + } + + const image = input.image; + if (!image) { + throw new Error("Image input must not be empty"); + } + if (image.startsWith("data:")) { + return resolveDataUriImage(image, input.maxBytes, input.fileName); + } + const localPath = resolveImageLocalPath(image); + if (localPath) { + return await resolveLocalUpload(localPath, input.maxBytes, input.localRoots, input.fileName); + } + + const buffer = decodeBase64Image({ + payload: image, + maxBytes: input.maxBytes, + invalidMessage: + "Invalid base64: image input is malformed. Use a data URI (data:image/png;base64,...) or a local file path instead.", + oversizedLabel: "Base64 image", + }); + return { buffer, fileName: input.fileName ?? "image.png" }; +} diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index 65ef287e8aff..5f9cd388d48f 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { FEISHU_HTTP_TIMEOUT_MS } from "./client-timeout.js"; import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-harness.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); @@ -332,6 +333,21 @@ describe("feishu_doc image fetch hardening", () => { expect(result.details.blocks_added).toBe(successChunkCount); }); + it("does not clear an existing document when Markdown conversion fails", async () => { + convertMock.mockResolvedValueOnce({ code: 999, msg: "unsupported Markdown" }); + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "write", + doc_token: "doc_1", + content: "
\nunsupported\n
", + }); + + expect(result.details.error).toContain("unsupported Markdown"); + expect(blockListMock).not.toHaveBeenCalled(); + expect(blockChildrenBatchDeleteMock).not.toHaveBeenCalled(); + }); + it("keeps fenced code blocks balanced when size fallback split is needed", async () => { const convertedChunks: string[] = []; let successChunkCount = 0; @@ -405,6 +421,12 @@ describe("feishu_doc image fetch hardening", () => { }); expect(readRemoteMediaBufferMock).toHaveBeenCalled(); + expect(readRemoteMediaBufferMock).toHaveBeenCalledWith( + expect.objectContaining({ + responseHeaderTimeoutMs: FEISHU_HTTP_TIMEOUT_MS, + readIdleTimeoutMs: FEISHU_HTTP_TIMEOUT_MS, + }), + ); expect(driveUploadAllMock).not.toHaveBeenCalled(); expect(blockPatchMock).not.toHaveBeenCalled(); expect(result.details.images_processed).toBe(0); @@ -412,6 +434,111 @@ describe("feishu_doc image fetch hardening", () => { consoleErrorSpy.mockRestore(); }); + it("degrades stalled markdown image URL reads through the docx image timeout", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + readRemoteMediaBufferMock.mockRejectedValueOnce( + new Error(`response body idle timeout after ${FEISHU_HTTP_TIMEOUT_MS}ms`), + ); + + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "write", + doc_token: "doc_1", + content: "![x](https://x.test/stalled.png)", + }); + + expect(readRemoteMediaBufferMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://x.test/stalled.png", + responseHeaderTimeoutMs: FEISHU_HTTP_TIMEOUT_MS, + readIdleTimeoutMs: FEISHU_HTTP_TIMEOUT_MS, + }), + ); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + expect(blockPatchMock).not.toHaveBeenCalled(); + expect(result.details.images_processed).toBe(0); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it("uses the selected account timeout for markdown image URL reads", async () => { + resolveFeishuToolAccountMock.mockReturnValue({ + config: { mediaMaxMb: 30, httpTimeoutMs: 1_234 }, + }); + readRemoteMediaBufferMock.mockResolvedValueOnce({ + buffer: Buffer.from("remote image", "utf8"), + fileName: "remote.png", + }); + + const feishuDocTool = resolveFeishuDocTool(); + + await executeFeishuDocTool(feishuDocTool, { + action: "write", + doc_token: "doc_1", + content: "![x](https://x.test/non-default-timeout.png)", + }); + + expect(readRemoteMediaBufferMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://x.test/non-default-timeout.png", + responseHeaderTimeoutMs: 1_234, + readIdleTimeoutMs: 1_234, + }), + ); + }); + + it("keeps remote Markdown images aligned after non-remote image blocks", async () => { + readRemoteMediaBufferMock.mockResolvedValueOnce({ + buffer: Buffer.from("remote image", "utf8"), + fileName: "remote.png", + }); + blockDescendantCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [ + { block_type: 27, block_id: "img_local" }, + { block_type: 27, block_id: "img_remote" }, + ], + }, + }); + + const feishuDocTool = resolveFeishuDocTool(); + const result = await executeFeishuDocTool(feishuDocTool, { + action: "write", + doc_token: "doc_1", + content: [ + "![local](data:image/png;base64,AAAA)", + "![remote](https://cdn.test/remote.png)", + ].join("\n"), + }); + + expect(readRemoteMediaBufferMock).toHaveBeenCalledTimes(1); + expect(readRemoteMediaBufferMock).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://cdn.test/remote.png" }), + ); + expect(blockPatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: { document_id: "doc_1", block_id: "img_remote" }, + }), + ); + expect(result.details.images_processed).toBe(1); + }); + + it("does not fetch Markdown image syntax inside fenced code", async () => { + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "write", + doc_token: "doc_1", + content: "```md\n![example](https://fake.test/code.png)\n```", + }); + + expect(readRemoteMediaBufferMock).not.toHaveBeenCalled(); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + expect(result.details.images_processed).toBe(0); + }); + it("create grants permission only to trusted Feishu requester", async () => { const feishuDocTool = resolveFeishuDocTool({ messageChannel: "feishu", @@ -600,6 +727,111 @@ describe("feishu_doc image fetch hardening", () => { expectLoadWebMediaCall("test-local.png", [WORKSPACE_ROOT]); }); + it("passes docx image read timeouts when upload_image reads a remote URL", async () => { + readRemoteMediaBufferMock.mockResolvedValueOnce({ + buffer: Buffer.from("remote image", "utf8"), + fileName: "remote.png", + }); + + const feishuDocTool = resolveFeishuDocTool(); + + await executeFeishuDocTool(feishuDocTool, { + action: "upload_image", + doc_token: "doc_1", + url: "https://x.test/remote.png", + filename: "remote.png", + }); + + expect(readRemoteMediaBufferMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://x.test/remote.png", + responseHeaderTimeoutMs: FEISHU_HTTP_TIMEOUT_MS, + readIdleTimeoutMs: FEISHU_HTTP_TIMEOUT_MS, + }), + ); + }); + + it("does not create an image block when a remote upload cannot be read", async () => { + readRemoteMediaBufferMock.mockRejectedValueOnce(new Error("response body idle timeout")); + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "upload_image", + doc_token: "doc_1", + url: "https://cdn.test/stalled.png", + }); + + expect(result.details.error).toContain("idle timeout"); + expect(blockChildrenCreateMock).not.toHaveBeenCalled(); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + }); + + it("rejects malformed base64 before creating an image block", async () => { + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "upload_image", + doc_token: "doc_1", + image: "A", + }); + + expect(result.details.error).toContain("Invalid base64"); + expect(blockChildrenCreateMock).not.toHaveBeenCalled(); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + }); + + it("rejects oversized base64 before creating an image block", async () => { + resolveFeishuToolAccountMock.mockReturnValue({ + config: { mediaMaxMb: 1 / (1024 * 1024) }, + }); + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "upload_image", + doc_token: "doc_1", + image: Buffer.alloc(32).toString("base64"), + }); + + expect(result.details.error).toContain("exceeds limit"); + expect(blockChildrenCreateMock).not.toHaveBeenCalled(); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + }); + + it("does not apply image-read timeouts to remote file uploads", async () => { + readRemoteMediaBufferMock.mockResolvedValueOnce({ + buffer: Buffer.from("remote file", "utf8"), + fileName: "](/unexpected) ![image](https://attacker.test/image.png)", + }); + blockChildrenCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [{ block_type: 2, block_id: "placeholder_block_1" }], + }, + }); + const feishuDocTool = resolveFeishuDocTool(); + + const result = await executeFeishuDocTool(feishuDocTool, { + action: "upload_file", + doc_token: "doc_1", + url: "https://cdn.test/remote.txt", + }); + + expect(result.details.success).toBe(true); + const remoteReadInput = requireRecord( + callArg(readRemoteMediaBufferMock, 0, 0, "remote media input"), + "remote media input", + ); + expect(remoteReadInput.url).toBe("https://cdn.test/remote.txt"); + expect(remoteReadInput).not.toHaveProperty("responseHeaderTimeoutMs"); + expect(remoteReadInput).not.toHaveProperty("readIdleTimeoutMs"); + expect(convertMock).toHaveBeenCalledWith({ + data: { + content_type: "markdown", + content: "[file](https://example.com/placeholder)", + }, + }); + }); + it("passes workspace localRoots for upload_image absolute local paths when workspace-only policy is active", async () => { const fixtureDir = path.join(process.cwd(), ".tmp-docx-upload-image-absolute"); const absoluteImagePath = path.join(fixtureDir, "absolute-image.png"); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index d5f241283b2f..8276b7af5b28 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -1,19 +1,23 @@ // Feishu plugin module implements docx behavior. -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { isAbsolute, resolve } from "node:path"; -import { basename } from "node:path"; +import { resolve } from "node:path"; import type * as Lark from "@larksuiteoapi/node-sdk"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { jsonResult as json } from "openclaw/plugin-sdk/tool-results"; import { Type } from "typebox"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; +import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js"; import { updateColorText } from "./docx-color-text.js"; +import { + createDocxMarkdownChunk, + createDocxMarkdownPlan, + splitDocxMarkdownBySize, + type DocxMarkdownChunk, + type DocxMarkdownImage, +} from "./docx-markdown.js"; import { cleanBlocksForDescendant, insertTableRow, @@ -23,7 +27,7 @@ import { mergeTableCells, } from "./docx-table-ops.js"; import type { FeishuDocxBlock, FeishuDocxBlockChild } from "./docx-types.js"; -import { getFeishuRuntime } from "./runtime.js"; +import { resolveDocxUploadInput } from "./docx-upload-input.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig, @@ -48,24 +52,6 @@ function resolveDocToolLocalRoots(ctx: { return [resolve(workspaceDir)]; } -/** Extract image URLs from markdown content */ -function extractImageUrls(markdown: string): string[] { - const regex = /!\[[^\]]*\]\(([^)]+)\)/g; - const urls: string[] = []; - let match; - while ((match = regex.exec(markdown)) !== null) { - const capturedUrl = match[1]; - if (capturedUrl === undefined) { - continue; - } - const url = capturedUrl.trim(); - if (url.startsWith("http://") || url.startsWith("https://")) { - urls.push(url); - } - } - return urls; -} - const BLOCK_TYPE_NAMES: Record = { 1: "Page", 2: "Text", @@ -304,113 +290,54 @@ async function insertBlocks( return { children: allInserted, skipped }; } -/** Split markdown into chunks at top-level headings (# or ##) to stay within API content limits */ -function splitMarkdownByHeadings(markdown: string): string[] { - const lines = markdown.split("\n"); - const chunks: string[] = []; - let current: string[] = []; - let inFencedBlock = false; - - for (const line of lines) { - if (/^(`{3,}|~{3,})/.test(line)) { - inFencedBlock = !inFencedBlock; - } - if (!inFencedBlock && /^#{1,2}\s/.test(line) && current.length > 0) { - chunks.push(current.join("\n")); - current = []; - } - current.push(line); - } - if (current.length > 0) { - chunks.push(current.join("\n")); - } - return chunks; -} - -/** Split markdown by size, preferring to break outside fenced code blocks when possible */ -function splitMarkdownBySize(markdown: string, maxChars: number): string[] { - if (markdown.length <= maxChars) { - return [markdown]; - } - - const lines = markdown.split("\n"); - const chunks: string[] = []; - let current: string[] = []; - let currentLength = 0; - let inFencedBlock = false; - - for (const line of lines) { - if (/^(`{3,}|~{3,})/.test(line)) { - inFencedBlock = !inFencedBlock; - } - - const lineLength = line.length + 1; - const wouldExceed = currentLength + lineLength > maxChars; - if (current.length > 0 && wouldExceed && !inFencedBlock) { - chunks.push(current.join("\n")); - current = []; - currentLength = 0; - } - - current.push(line); - currentLength += lineLength; - } - - if (current.length > 0) { - chunks.push(current.join("\n")); - } - - if (chunks.length > 1) { - return chunks; - } - - // Degenerate case: no safe boundary outside fenced content. - const midpoint = Math.floor(lines.length / 2); - if (midpoint <= 0 || midpoint >= lines.length) { - return [markdown]; - } - return [lines.slice(0, midpoint).join("\n"), lines.slice(midpoint).join("\n")]; -} - -async function convertMarkdownWithFallback(client: Lark.Client, markdown: string, depth = 0) { +async function convertMarkdownWithFallback( + client: Lark.Client, + chunk: DocxMarkdownChunk, + depth = 0, +) { try { - return await convertMarkdown(client, markdown); + return { ...(await convertMarkdown(client, chunk.markdown)), images: chunk.images }; } catch (error) { - if (depth >= MAX_CONVERT_RETRY_DEPTH || markdown.length < 2) { + if (depth >= MAX_CONVERT_RETRY_DEPTH || chunk.markdown.length < 2) { throw error; } - const splitTarget = Math.max(256, Math.floor(markdown.length / 2)); - const chunks = splitMarkdownBySize(markdown, splitTarget); + const splitTarget = Math.max(256, Math.floor(chunk.markdown.length / 2)); + const chunks = splitDocxMarkdownBySize(chunk.markdown, splitTarget).map( + createDocxMarkdownChunk, + ); if (chunks.length <= 1) { throw error; } const blocks: FeishuDocxBlock[] = []; const firstLevelBlockIds: string[] = []; + const images: DocxMarkdownImage[] = []; - for (const chunk of chunks) { - const converted = await convertMarkdownWithFallback(client, chunk, depth + 1); + for (const fallbackChunk of chunks) { + const converted = await convertMarkdownWithFallback(client, fallbackChunk, depth + 1); blocks.push(...converted.blocks); firstLevelBlockIds.push(...converted.firstLevelBlockIds); + images.push(...converted.images); } - return { blocks, firstLevelBlockIds }; + return { blocks, firstLevelBlockIds, images }; } } /** Convert markdown in chunks to avoid document.convert content size limits */ -async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) { - const chunks = splitMarkdownByHeadings(markdown); +async function chunkedConvertMarkdown(client: Lark.Client, chunks: readonly DocxMarkdownChunk[]) { const allBlocks: FeishuDocxBlock[] = []; const allRootIds: string[] = []; + const allImages: DocxMarkdownImage[] = []; for (const chunk of chunks) { - const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk); + const { blocks, firstLevelBlockIds, images } = await convertMarkdownWithFallback(client, chunk); const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); allBlocks.push(...orderedBlocks); allRootIds.push(...rootIds); + allImages.push(...images); } - return { blocks: allBlocks, firstLevelBlockIds: allRootIds }; + return { blocks: allBlocks, firstLevelBlockIds: allRootIds, images: allImages }; } type Logger = { info?: (msg: string) => void }; @@ -507,181 +434,41 @@ async function uploadImageToDocx( return fileToken; } -async function downloadImage(url: string, maxBytes: number): Promise { - const fetched = await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ url, maxBytes }); - return fetched.buffer; -} - -async function resolveUploadInput( - url: string | undefined, - filePath: string | undefined, - maxBytes: number, - localRoots?: readonly string[], - explicitFileName?: string, - imageInput?: string, // data URI, plain base64, or local path -): Promise<{ buffer: Buffer; fileName: string }> { - // Enforce mutual exclusivity: exactly one input source must be provided. - const inputSources = ( - [url ? "url" : null, filePath ? "file_path" : null, imageInput ? "image" : null] as ( - | string - | null - )[] - ).filter(Boolean); - if (inputSources.length > 1) { - throw new Error(`Provide only one image source; got: ${inputSources.join(", ")}`); - } - - // data URI: data:image/png;base64,xxxx - if (imageInput?.startsWith("data:")) { - const commaIdx = imageInput.indexOf(","); - if (commaIdx === -1) { - throw new Error("Invalid data URI: missing comma separator."); - } - const header = imageInput.slice(0, commaIdx); - const data = imageInput.slice(commaIdx + 1); - // Only base64-encoded data URIs are supported; reject plain/URL-encoded ones. - if (!header.includes(";base64")) { - throw new Error( - `Invalid data URI: missing ';base64' marker. ` + - `Expected format: data:image/png;base64,`, - ); - } - // Validate the payload is actually base64 before decoding; Node's decoder - // is permissive and would silently accept garbage bytes otherwise. - const trimmedData = data.trim(); - if (trimmedData.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmedData)) { - throw new Error( - `Invalid data URI: base64 payload contains characters outside the standard alphabet.`, - ); - } - const mimeMatch = header.match(/data:([^;]+)/); - const ext = extensionForMime(mimeMatch?.[1])?.slice(1) ?? "png"; - // Estimate decoded byte count from base64 length BEFORE allocating the - // full buffer to avoid spiking memory on oversized payloads. - const estimatedBytes = Math.ceil((trimmedData.length * 3) / 4); - if (estimatedBytes > maxBytes) { - throw new Error( - `Image data URI exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`, - ); - } - const buffer = Buffer.from(trimmedData, "base64"); - return { buffer, fileName: explicitFileName ?? `image.${ext}` }; - } - - // local path: ~, ./ and ../ are unambiguous (not in base64 alphabet). - // Absolute paths (/...) are supported but must exist on disk. If an absolute - // path does not exist we throw immediately rather than falling through to - // base64 decoding, which would silently upload garbage bytes. - // Note: JPEG base64 starts with "/9j/" — pass as data:image/jpeg;base64,... - // to avoid ambiguity with absolute paths. - if (imageInput) { - const candidate = imageInput.startsWith("~") ? imageInput.replace(/^~/, homedir()) : imageInput; - const unambiguousPath = - imageInput.startsWith("~") || imageInput.startsWith("./") || imageInput.startsWith("../"); - const absolutePath = isAbsolute(imageInput); - - if (unambiguousPath || (absolutePath && existsSync(candidate))) { - // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu). - const resolvedPath = resolve(candidate); - const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedPath, { - maxBytes, - optimizeImages: false, - localRoots, - }); - return { buffer: loaded.buffer, fileName: explicitFileName ?? basename(candidate) }; - } - - if (absolutePath && !existsSync(candidate)) { - throw new Error( - `File not found: "${candidate}". ` + - `If you intended to pass image binary data, use a data URI instead: data:image/jpeg;base64,...`, - ); - } - } - - // plain base64 string (standard base64 alphabet includes '+', '/', '=') - if (imageInput) { - const trimmed = imageInput.trim(); - // Node's Buffer.from is permissive and silently ignores out-of-alphabet chars, - // which would decode malformed strings into arbitrary bytes. Reject early. - if (trimmed.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmed)) { - throw new Error( - `Invalid base64: image input contains characters outside the standard base64 alphabet. ` + - `Use a data URI (data:image/png;base64,...) or a local file path instead.`, - ); - } - // Estimate decoded byte count from base64 length BEFORE allocating the - // full buffer to avoid spiking memory on oversized payloads. - const estimatedBytes = Math.ceil((trimmed.length * 3) / 4); - if (estimatedBytes > maxBytes) { - throw new Error( - `Base64 image exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`, - ); - } - const buffer = Buffer.from(trimmed, "base64"); - if (buffer.length === 0) { - throw new Error("Base64 image decoded to empty buffer; check the input."); - } - return { buffer, fileName: explicitFileName ?? "image.png" }; - } - - if (!url && !filePath) { - throw new Error("Either url, file_path, or image (base64/data URI) must be provided"); - } - if (url && filePath) { - throw new Error("Provide only one of url or file_path"); - } - - if (url) { - const fetched = await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ url, maxBytes }); - const urlPath = new URL(url).pathname; - const guessed = urlPath.split("/").pop() || "upload.bin"; - return { - buffer: fetched.buffer, - fileName: explicitFileName || guessed, - }; - } - - // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu). - const resolvedFilePath = resolve(filePath!); - const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedFilePath, { - maxBytes, - optimizeImages: false, - localRoots, - }); - return { - buffer: loaded.buffer, - fileName: explicitFileName || basename(filePath!), - }; -} - async function processImages( client: Lark.Client, docToken: string, - markdown: string, + images: readonly DocxMarkdownImage[], insertedBlocks: FeishuDocxBlockChild[], maxBytes: number, + imageReadTimeoutMs: number, ): Promise { - const imageUrls = extractImageUrls(markdown); - if (imageUrls.length === 0) { + if (images.length === 0) { return 0; } const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27); let processed = 0; - for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) { - const url = imageUrls[i]; + for (let i = 0; i < Math.min(images.length, imageBlocks.length); i++) { + const url = images[i]?.url; const blockId = imageBlocks[i]?.block_id; if (!url || !blockId) { continue; } try { - const buffer = await downloadImage(url, maxBytes); - const urlPath = new URL(url).pathname; - const fileName = urlPath.split("/").pop() || `image_${i}.png`; - const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName, docToken); + const upload = await resolveDocxUploadInput({ + url, + maxBytes, + remoteReadTimeoutMs: imageReadTimeoutMs, + }); + const fileToken = await uploadImageToDocx( + client, + blockId, + upload.buffer, + upload.fileName, + docToken, + ); await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, @@ -703,6 +490,7 @@ async function uploadImageBlock( client: Lark.Client, docToken: string, maxBytes: number, + imageReadTimeoutMs: number, localRoots?: readonly string[], url?: string, filePath?: string, @@ -711,7 +499,18 @@ async function uploadImageBlock( index?: number, imageInput?: string, // data URI, plain base64, or local path ) { - // Step 1: Create an empty image block (block_type 27). + // Resolve first so rejected or stalled input cannot leave an empty document block behind. + const upload = await resolveDocxUploadInput({ + url, + filePath, + image: imageInput, + maxBytes, + localRoots, + fileName: filename, + remoteReadTimeoutMs: imageReadTimeoutMs, + }); + + // Create an empty image block (block_type 27). // Per Feishu FAQ: image token cannot be set at block creation time. const insertRes = await client.docx.documentBlockChildren.create({ path: { document_id: docToken, block_id: parentBlockId ?? docToken }, @@ -726,15 +525,6 @@ async function uploadImageBlock( throw new Error("Failed to create image block"); } - // Step 2: Resolve and upload the image buffer. - const upload = await resolveUploadInput( - url, - filePath, - maxBytes, - localRoots, - filename, - imageInput, - ); const fileToken = await uploadImageToDocx( client, imageBlockId, @@ -743,7 +533,7 @@ async function uploadImageBlock( docToken, // drive_route_token for multi-datacenter routing ); - // Step 3: Set the image token on the block. + // Set the image token on the block. const patchRes = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: imageBlockId }, data: { replace_image: { token: fileToken } }, @@ -776,10 +566,16 @@ async function uploadFileBlock( // Feishu API does not allow creating empty file blocks (block_type 23). // Workaround: create a placeholder text block, then replace it with file content. // Actually, file blocks need a different approach: use markdown link as placeholder. - const upload = await resolveUploadInput(url, filePath, maxBytes, localRoots, filename); + const upload = await resolveDocxUploadInput({ + url, + filePath, + maxBytes, + localRoots, + fileName: filename, + }); // Create a placeholder text block first - const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`; + const placeholderMd = "[file](https://example.com/placeholder)"; const converted = await convertMarkdown(client, placeholderMd); const { orderedBlocks } = normalizeConvertedBlockTree( converted.blocks, @@ -949,11 +745,18 @@ async function writeDoc( docToken: string, markdown: string, maxBytes: number, + imageReadTimeoutMs: number, logger?: Logger, ) { - const deleted = await clearDocumentContent(client, docToken); + const markdownPlan = createDocxMarkdownPlan(markdown); logger?.info?.("feishu_doc: Converting markdown..."); - const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); + const { blocks, firstLevelBlockIds, images } = await chunkedConvertMarkdown( + client, + markdownPlan.chunks, + ); + // Complete fallible conversion before deleting existing content so an + // unsupported oversized construct cannot leave the document empty. + const deleted = await clearDocumentContent(client, docToken); if (blocks.length === 0) { return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 }; } @@ -964,7 +767,14 @@ async function writeDoc( blocks.length > BATCH_SIZE ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger) : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds); - const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); + const imagesProcessed = await processImages( + client, + docToken, + images, + inserted, + maxBytes, + imageReadTimeoutMs, + ); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); return { @@ -980,10 +790,15 @@ async function appendDoc( docToken: string, markdown: string, maxBytes: number, + imageReadTimeoutMs: number, logger?: Logger, ) { + const markdownPlan = createDocxMarkdownPlan(markdown); logger?.info?.("feishu_doc: Converting markdown..."); - const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); + const { blocks, firstLevelBlockIds, images } = await chunkedConvertMarkdown( + client, + markdownPlan.chunks, + ); if (blocks.length === 0) { throw new Error("Content is empty"); } @@ -994,7 +809,14 @@ async function appendDoc( blocks.length > BATCH_SIZE ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger) : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds); - const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); + const imagesProcessed = await processImages( + client, + docToken, + images, + inserted, + maxBytes, + imageReadTimeoutMs, + ); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); return { @@ -1011,8 +833,10 @@ async function insertDoc( markdown: string, afterBlockId: string, maxBytes: number, + imageReadTimeoutMs: number, logger?: Logger, ) { + const markdownPlan = createDocxMarkdownPlan(markdown); const blockInfo = await client.docx.documentBlock.get({ path: { document_id: docToken, block_id: afterBlockId }, }); @@ -1049,7 +873,10 @@ async function insertDoc( const insertIndex = blockIndex + 1; logger?.info?.("feishu_doc: Converting markdown..."); - const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); + const { blocks, firstLevelBlockIds, images } = await chunkedConvertMarkdown( + client, + markdownPlan.chunks, + ); if (blocks.length === 0) { throw new Error("Content is empty"); } @@ -1074,7 +901,14 @@ async function insertDoc( index: insertIndex, }); - const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); + const imagesProcessed = await processImages( + client, + docToken, + images, + inserted, + maxBytes, + imageReadTimeoutMs, + ); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); return { @@ -1400,6 +1234,19 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { 1024 * 1024; + const getImageReadTimeoutMs = ( + params: { accountId?: string } | undefined, + defaultAccountId?: string, + ) => + resolveConfiguredHttpTimeoutMs( + resolveFeishuToolAccount({ + api, + executeParams: params, + defaultAccountId, + requiredTool: { family: "doc", label: "Doc" }, + }), + ); + // Main document tool with action-based dispatch if (toolsCfg.doc) { api.registerTool( @@ -1430,6 +1277,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { p.doc_token, p.content, getMediaMaxBytes(p, defaultAccountId), + getImageReadTimeoutMs(p, defaultAccountId), api.logger, ), ); @@ -1440,6 +1288,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { p.doc_token, p.content, getMediaMaxBytes(p, defaultAccountId), + getImageReadTimeoutMs(p, defaultAccountId), api.logger, ), ); @@ -1451,6 +1300,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { p.content, p.after_block_id, getMediaMaxBytes(p, defaultAccountId), + getImageReadTimeoutMs(p, defaultAccountId), api.logger, ), ); @@ -1502,6 +1352,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { client, p.doc_token, getMediaMaxBytes(p, defaultAccountId), + getImageReadTimeoutMs(p, defaultAccountId), mediaLocalRoots, p.url, p.file_path, diff --git a/extensions/feishu/src/markdown.ts b/extensions/feishu/src/markdown.ts index 831750710ab5..5c919fb3ff2f 100644 --- a/extensions/feishu/src/markdown.ts +++ b/extensions/feishu/src/markdown.ts @@ -5,13 +5,16 @@ import { gfmTable } from "micromark-extension-gfm-table"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import type { MentionTarget } from "./mention-target.types.js"; -type PositionedMarkdownNode = { +export type FeishuMarkdownNode = { type: string; + depth?: number; + identifier?: string; + url?: string; position?: { start: { offset?: number }; end: { offset?: number }; }; - children?: PositionedMarkdownNode[]; + children?: FeishuMarkdownNode[]; }; type FeishuPostMessageElement = @@ -20,6 +23,14 @@ type FeishuPostMessageElement = const FEISHU_POST_MAX_BYTES = 30 * 1024; +/** One parser contract for Feishu message and document Markdown decisions. */ +export function parseFeishuMarkdown(text: string): FeishuMarkdownNode { + return fromMarkdown(text, { + extensions: [gfmTable()], + mdastExtensions: [gfmTableFromMarkdown()], + }) as FeishuMarkdownNode; +} + function buildFeishuPostMentionElements(mentions?: MentionTarget[]): FeishuPostMessageElement[] { if (!mentions?.length) { return []; @@ -66,10 +77,7 @@ export function assertFeishuPostWithinEnvelope(content: string, label: string): } function collectSoftBreakOffsets(text: string): number[] { - const root = fromMarkdown(text, { - extensions: [gfmTable()], - mdastExtensions: [gfmTableFromMarkdown()], - }) as PositionedMarkdownNode; + const root = parseFeishuMarkdown(text); const offsets: number[] = []; const pending = [root];