mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
d8b3e1c093
commit
dbbe012256
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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: "",
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
+132
-281
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user