function resolveDocToolLocalRoots(ctx: {
workspaceDir?: string;
fsPolicy?: { workspaceOnly: boolean };
}): string[] | undefined { if (ctx.fsPolicy?.workspaceOnly !== true) { return undefined;
} const workspaceDir = ctx.workspaceDir?.trim(); // Fail closed: workspace-only with no resolved workspace must not fall back // to default managed roots. if (!workspaceDir) { return [];
} // Workspace paths are expected to be absolute; resolve() normalizes any // accidental relative input before passing roots to loadWebMedia. 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 url = match[1].trim(); if (url.startsWith("http://") || url.startsWith("https://")) {
urls.push(url);
}
} return urls;
}
// Convert API may return `blocks` in a non-render order. // Reconstruct the document tree using first_level_block_ids plus children/parent links, // then emit blocks in pre-order so Descendant/Children APIs receive one normalized tree contract. function normalizeConvertedBlockTree(
blocks: FeishuDocxBlock[],
firstLevelIds: string[],
): { orderedBlocks: FeishuDocxBlock[]; rootIds: string[] } { if (blocks.length <= 1) { const rootIds =
blocks.length === 1 && typeof blocks[0]?.block_id === "string" ? [blocks[0].block_id] : []; return { orderedBlocks: blocks, rootIds };
}
const byId = new Map<string, FeishuDocxBlock>(); const originalOrder = new Map<string, number>(); for (const [index, block] of blocks.entries()) { if (typeof block?.block_id === "string") {
byId.set(block.block_id, block);
originalOrder.set(block.block_id, index);
}
}
const childIds = new Set<string>(); for (const block of blocks) { for (const childId of normalizeChildIds(block?.children)) {
childIds.add(childId);
}
}
const orderedBlocks: FeishuDocxBlock[] = []; const visited = new Set<string>();
const visit = (blockId: string) => { if (!byId.has(blockId) || visited.has(blockId)) { return;
}
visited.add(blockId); const block = byId.get(blockId); if (!block) { return;
}
orderedBlocks.push(block); for (const childId of normalizeChildIds(block?.children)) {
visit(childId);
}
};
for (const rootId of rootIds) {
visit(rootId);
}
// Fallback for malformed/partial trees from Convert API: keep any leftovers in original order. for (const block of blocks) { if (typeof block?.block_id === "string") {
visit(block.block_id);
} else {
orderedBlocks.push(block);
}
}
return { orderedBlocks, rootIds: rootIds.filter((id): id is string => typeof id === "string") };
}
// Insert blocks one at a time to preserve document order. // The batch API (sending all children at once) does not guarantee ordering // because Feishu processes the batch asynchronously. Sequential single-block // inserts (each appended to the end) produce deterministic results. const allInserted: FeishuDocxBlockChild[] = []; for (const [offset, block] of cleaned.entries()) { const res = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: blockId },
data: {
children: [toCreateChildBlock(block)],
...(index !== undefined ? { index: index + offset } : {}),
},
}); if (res.code !== 0) { thrownew Error(res.msg);
}
allInserted.push(...(res.data?.children ?? []));
} 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;
}
async function uploadImageToDocx(
client: Lark.Client,
blockId: string,
imageBuffer: Buffer,
fileName: string,
docToken?: string,
): Promise<string> { const res = await client.drive.media.uploadAll({
data: {
file_name: fileName,
parent_type: "docx_image",
parent_node: blockId,
size: imageBuffer.length, // Pass Buffer directly so form-data can calculate Content-Length correctly. // Readable.from() produces a stream with unknown length, causing Content-Length // mismatch that silently truncates uploads for images larger than ~1KB.
file: imageBuffer as DriveMediaUploadFile, // Required when the document block belongs to a non-default datacenter: // tells the drive service which document the block belongs to for routing. // Per API docs: certain upload scenarios require the cloud document token.
...(docToken ? { extra: JSON.stringify({ drive_route_token: docToken }) } : {}),
},
});
const fileToken = res?.file_token; if (!fileToken) { thrownew Error("Image upload failed: no file_token returned");
} return fileToken;
}
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) { thrownew 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) { thrownew 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")) { thrownew 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)) { thrownew Error(
`Invalid data URI: base64 payload contains characters outside the standard alphabet.`,
);
} const mimeMatch = header.match(/data:([^;]+)/); const ext = mimeMatch?.[1]?.split("/")[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) { thrownew 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 (absolutePath && !existsSync(candidate)) { thrownew 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)) { thrownew 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) { thrownew Error(
`Base64 image exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`,
);
} const buffer = Buffer.from(trimmed, "base64"); if (buffer.length === 0) { thrownew Error("Base64 image decoded to empty buffer; check the input.");
} return { buffer, fileName: explicitFileName ?? "image.png" };
}
if (!url && !filePath) { thrownew Error("Either url, file_path, or image (base64/data URI) must be provided");
} if (url && filePath) { thrownew Error("Provide only one of url or file_path");
}
// 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);
// Get the first inserted block - we'll delete it and create the file in its place const placeholderBlock = inserted[0]; if (!placeholderBlock?.block_id) { thrownew Error("Failed to create placeholder block for file upload");
}
const fileToken = fileRes?.file_token; if (!fileToken) { thrownew Error("File upload failed: no file_token returned");
}
return {
success: true,
file_token: fileToken,
file_name: upload.fileName,
size: upload.buffer.length,
note: "File uploaded to drive. Use the file_token to reference it. Direct file block creation is not supported by the Feishu API.",
};
}
for (const b of blocks) { const type = b.block_type ?? 0; const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
blockCounts[name] = (blockCounts[name] || 0) + 1;
if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
structuredTypes.push(name);
}
}
let hint: string | undefined; if (structuredTypes.length > 0) {
hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
}
// Paginate through all children to reliably locate after_block_id. // documentBlockChildren.get returns up to 200 children per page; large // parents require multiple requests. const items: FeishuDocxBlock[] = [];
let pageToken: string | undefined; do { const childrenRes = await client.docx.documentBlockChildren.get({
path: { document_id: docToken, block_id: parentId },
params: pageToken ? { page_token: pageToken } : {},
}); if (childrenRes.code !== 0) { thrownew Error(childrenRes.msg);
}
items.push(...(childrenRes.data?.items ?? []));
pageToken = childrenRes.data?.page_token ?? undefined;
} while (pageToken);
const blockIndex = items.findIndex((item) => item.block_id === afterBlockId); if (blockIndex === -1) { thrownew Error(
`after_block_id "${afterBlockId}" was not found among the children of parent block "${parentId}". ` +
`Use list_blocks to verify the block ID.`,
);
} const insertIndex = blockIndex + 1;
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.