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 <li.long15@xydigit.com>

* refactor(feishu): keep docx helper types internal

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-15 12:39:17 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent d8b3e1c093
commit dbbe012256
7 changed files with 985 additions and 287 deletions
+1
View File
@@ -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.
+154
View File
@@ -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");
}
});
});
+291
View File
@@ -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<string, string>();
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];
}
+161
View File
@@ -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,<base64data>",
);
}
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" };
}
+232
View File
@@ -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: "<section>\nunsupported\n</section>",
});
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");
+132 -281
View File
@@ -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<number, string> = {
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<Buffer> {
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,<base64data>`,
);
}
// 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<number> {
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,
+14 -6
View File
@@ -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];