From dbbe0122564e4d62a4bfd3217d3ccc941aa6f3eb Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Thu, 16 Jul 2026 03:39:17 +0800
Subject: [PATCH] fix(feishu): bound stalled docx image URL reads (#104663)
* fix(feishu): bound stalled docx image URL reads
* fix(feishu): honor configured docx image timeout
* fix(feishu): avoid total deadline for docx images
* refactor(feishu): extract docx upload input handling
* fix(feishu): harden document image uploads
Co-authored-by: Alix-007
* refactor(feishu): keep docx helper types internal
---------
Co-authored-by: Peter Steinberger
---
CHANGELOG.md | 1 +
extensions/feishu/src/docx-markdown.test.ts | 154 ++++++++
extensions/feishu/src/docx-markdown.ts | 291 ++++++++++++++
extensions/feishu/src/docx-upload-input.ts | 161 ++++++++
extensions/feishu/src/docx.test.ts | 232 +++++++++++
extensions/feishu/src/docx.ts | 413 +++++++-------------
extensions/feishu/src/markdown.ts | 20 +-
7 files changed, 985 insertions(+), 287 deletions(-)
create mode 100644 extensions/feishu/src/docx-markdown.test.ts
create mode 100644 extensions/feishu/src/docx-markdown.ts
create mode 100644 extensions/feishu/src/docx-upload-input.ts
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 = [
+ "",
+ "",
+ "```md",
+ "",
+ "```",
+ "",
+ '.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",
+ "",
+ "",
+ "[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: "",
+ });
+
+ 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: "",
+ });
+
+ 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: "",
+ });
+
+ 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: [
+ "",
+ "",
+ ].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\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) ",
+ });
+ 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];