import { r as listEnabledFeishuAccounts } from "./accounts-Cfzht2Hc.js"; import { a as setFeishuNamedAccountEnabled, i as feishuSetupAdapter, n as feishuSetupWizard, r as runFeishuLogin, t as feishuPlugin } from "./channel-uEJPBCR9.js"; import { a as parseFeishuTargetId, i as parseFeishuDirectConversationId, n as buildFeishuModelOverrideParentCandidates, r as parseFeishuConversationId, t as buildFeishuConversationId } from "./conversation-id-DuL575sn.js"; import { t as getFeishuRuntime } from "./runtime-C5JxBWZp.js"; import { r as createFeishuClient } from "./client-BwgAJuvo.js"; import { a as jsonToolResult, d as registerFeishuChatTools, f as createFeishuToolClient, h as resolveToolsConfig, m as resolveFeishuToolAccount, n as registerFeishuDriveTools, o as toolExecutionErrorResult, p as resolveAnyEnabledFeishuToolsConfig, s as unknownToolActionResult } from "./drive-djUl8IRC.js"; import { n as getFeishuThreadBindingManager, r as testing, t as createFeishuThreadBindingManager } from "./thread-bindings-V0bwk0A1.js"; import { n as handleFeishuSubagentEnded, r as handleFeishuSubagentSpawning, t as handleFeishuSubagentDeliveryTarget } from "./subagent-hooks-1pqt5tAA.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, readStringValue, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { optionalPositiveIntegerSchema } from "openclaw/plugin-sdk/channel-actions"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, isAbsolute, resolve } from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import { Type } from "typebox"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; import { createClackPrompter } from "openclaw/plugin-sdk/setup-runtime"; //#region extensions/feishu/src/doc-schema.ts const tableCreationProperties = { doc_token: Type.String({ description: "Document token" }), parent_block_id: Type.Optional(Type.String({ description: "Parent block ID (default: document root)" })), row_size: Type.Integer({ description: "Table row count", minimum: 1 }), column_size: Type.Integer({ description: "Table column count", minimum: 1 }), column_width: Type.Optional(Type.Array(Type.Number({ minimum: 1 }), { description: "Column widths in px (length should match column_size)" })) }; const FeishuDocSchema = Type.Union([ Type.Object({ action: Type.Literal("read"), doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }) }), Type.Object({ action: Type.Literal("write"), doc_token: Type.String({ description: "Document token" }), content: Type.String({ description: "Markdown content to write (replaces entire document content)" }) }), Type.Object({ action: Type.Literal("append"), doc_token: Type.String({ description: "Document token" }), content: Type.String({ description: "Markdown content to append to end of document" }) }), Type.Object({ action: Type.Literal("insert"), doc_token: Type.String({ description: "Document token" }), content: Type.String({ description: "Markdown content to insert" }), after_block_id: Type.String({ description: "Insert content after this block ID. Use list_blocks to find block IDs." }) }), Type.Object({ action: Type.Literal("create"), title: Type.String({ description: "Document title" }), folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })), grant_to_requester: Type.Optional(Type.Boolean({ description: "Grant edit permission to the trusted requesting Feishu user from runtime context (default: true)." })) }), Type.Object({ action: Type.Literal("list_blocks"), doc_token: Type.String({ description: "Document token" }) }), Type.Object({ action: Type.Literal("get_block"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Block ID (from list_blocks)" }) }), Type.Object({ action: Type.Literal("update_block"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Block ID (from list_blocks)" }), content: Type.String({ description: "New text content" }) }), Type.Object({ action: Type.Literal("delete_block"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Block ID" }) }), Type.Object({ action: Type.Literal("create_table"), ...tableCreationProperties }), Type.Object({ action: Type.Literal("write_table_cells"), doc_token: Type.String({ description: "Document token" }), table_block_id: Type.String({ description: "Table block ID" }), values: Type.Array(Type.Array(Type.String()), { description: "2D matrix values[row][col] to write into table cells", minItems: 1 }) }), Type.Object({ action: Type.Literal("create_table_with_values"), ...tableCreationProperties, values: Type.Array(Type.Array(Type.String()), { description: "2D matrix values[row][col] to write into table cells", minItems: 1 }) }), Type.Object({ action: Type.Literal("insert_table_row"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Table block ID" }), row_index: Type.Optional(Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" })) }), Type.Object({ action: Type.Literal("insert_table_column"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Table block ID" }), column_index: Type.Optional(Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" })) }), Type.Object({ action: Type.Literal("delete_table_rows"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Table block ID" }), row_start: Type.Number({ description: "Start row index (0-based)" }), row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })) }), Type.Object({ action: Type.Literal("delete_table_columns"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Table block ID" }), column_start: Type.Number({ description: "Start column index (0-based)" }), column_count: Type.Optional(Type.Number({ description: "Number of columns to delete (default: 1)" })) }), Type.Object({ action: Type.Literal("merge_table_cells"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Table block ID" }), row_start: Type.Number({ description: "Start row index" }), row_end: Type.Number({ description: "End row index (exclusive)" }), column_start: Type.Number({ description: "Start column index" }), column_end: Type.Number({ description: "End column index (exclusive)" }) }), Type.Object({ action: Type.Literal("upload_image"), doc_token: Type.String({ description: "Document token" }), url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })), file_path: Type.Optional(Type.String({ description: "Local image file path" })), image: Type.Optional(Type.String({ description: "Image as data URI (data:image/png;base64,...) or plain base64 string. Use instead of url/file_path for DALL-E outputs, canvas screenshots, etc." })), parent_block_id: Type.Optional(Type.String({ description: "Parent block ID (default: document root)" })), filename: Type.Optional(Type.String({ description: "Optional filename override" })), index: Type.Optional(Type.Integer({ minimum: 0, description: "Insert position (0-based index among siblings). Omit to append." })) }), Type.Object({ action: Type.Literal("upload_file"), doc_token: Type.String({ description: "Document token" }), url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })), file_path: Type.Optional(Type.String({ description: "Local file path" })), parent_block_id: Type.Optional(Type.String({ description: "Parent block ID (default: document root)" })), filename: Type.Optional(Type.String({ description: "Optional filename override" })) }), Type.Object({ action: Type.Literal("color_text"), doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Text block ID to update" }), content: Type.String({ description: "Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: \"Revenue [green]+15%[/green] YoY\"" }) }) ]); //#endregion //#region extensions/feishu/src/docx-table-ops.ts const MIN_COLUMN_WIDTH = 50; const MAX_COLUMN_WIDTH = 400; const DEFAULT_TABLE_WIDTH = 730; /** * Calculate adaptive column widths based on cell content length. * * Algorithm: * 1. For each column, find the max content length across all rows * 2. Weight CJK characters as 2x width (they render wider) * 3. Calculate proportional widths based on content length * 4. Apply min/max constraints * 5. Redistribute remaining space to fill total table width * * Total width is derived from the original column_width values returned * by the Convert API, ensuring tables match Feishu's expected dimensions. * * @param blocks - Array of blocks from Convert API * @param tableBlockId - The block_id of the table block * @returns Array of column widths in pixels */ function normalizeChildBlockIds(children) { if (Array.isArray(children)) return children; return typeof children === "string" ? [children] : []; } function omitParentId(block) { const cleanBlock = { ...block }; delete cleanBlock.parent_id; return cleanBlock; } function createDescendantTable(table, adaptiveWidths) { const { row_size, column_size } = table.property || {}; return { property: { row_size, column_size, ...adaptiveWidths?.length ? { column_width: adaptiveWidths } : {} } }; } function calculateAdaptiveColumnWidths(blocks, tableBlockId) { const tableBlock = blocks.find((b) => b.block_id === tableBlockId && b.block_type === 31); if (!tableBlock?.table?.property) return []; const { row_size, column_size, column_width: originalWidths } = tableBlock.table.property; if (!row_size || !column_size) return []; const totalWidth = originalWidths && originalWidths.length > 0 ? originalWidths.reduce((a, b) => a + b, 0) : DEFAULT_TABLE_WIDTH; const cellIds = normalizeChildBlockIds(tableBlock.children); const blockMap = /* @__PURE__ */ new Map(); for (const block of blocks) if (block.block_id) blockMap.set(block.block_id, block); function getCellText(cellId) { const cell = blockMap.get(cellId); let text = ""; const childIds = normalizeChildBlockIds(cell?.children); for (const childId of childIds) { const child = blockMap.get(childId); if (child?.text?.elements) { for (const elem of child.text.elements) if (elem.text_run?.content) text += elem.text_run.content; } } return text; } function getWeightedLength(text) { return Array.from(text).reduce((sum, char) => { return sum + (char.charCodeAt(0) > 255 ? 2 : 1); }, 0); } const maxLengths = Array.from({ length: column_size }, () => 0); for (let row = 0; row < row_size; row++) for (let col = 0; col < column_size; col++) { const cellId = cellIds[row * column_size + col]; if (cellId) { const length = getWeightedLength(getCellText(cellId)); maxLengths[col] = Math.max(maxLengths[col], length); } } const totalLength = maxLengths.reduce((a, b) => a + b, 0); if (totalLength === 0) { const equalWidth = Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.floor(totalWidth / column_size))); return Array.from({ length: column_size }, () => equalWidth); } let widths = maxLengths.map((len) => { const proportion = len / totalLength; return Math.round(proportion * totalWidth); }); widths = widths.map((w) => Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, w))); let remaining = totalWidth - widths.reduce((a, b) => a + b, 0); while (remaining > 0) { const growable = widths.map((w, i) => w < MAX_COLUMN_WIDTH ? i : -1).filter((i) => i >= 0); if (growable.length === 0) break; const perColumn = Math.floor(remaining / growable.length); if (perColumn === 0) break; for (const i of growable) { const add = Math.min(perColumn, MAX_COLUMN_WIDTH - widths[i]); widths[i] += add; remaining -= add; } } return widths; } /** * Clean blocks for Descendant API with adaptive column widths. * * - Removes parent_id from all blocks * - Fixes children type (string → array) for TableCell blocks * - Removes merge_info (read-only, causes API error) * - Calculates and applies adaptive column_width for tables * * @param blocks - Array of blocks from Convert API * @returns Cleaned blocks ready for Descendant API */ function cleanBlocksForDescendant(blocks) { const tableWidths = /* @__PURE__ */ new Map(); for (const block of blocks) if (block.block_type === 31 && block.block_id) { const widths = calculateAdaptiveColumnWidths(blocks, block.block_id); tableWidths.set(block.block_id, widths); } return blocks.map((block) => { const cleanBlock = omitParentId(block); if (cleanBlock.block_type === 32 && typeof cleanBlock.children === "string") cleanBlock.children = [cleanBlock.children]; if (cleanBlock.block_type === 31 && cleanBlock.table) { const adaptiveWidths = block.block_id ? tableWidths.get(block.block_id) : void 0; cleanBlock.table = createDescendantTable(cleanBlock.table, adaptiveWidths); } return cleanBlock; }); } async function insertTableRow(client, docToken, blockId, rowIndex = -1) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { insert_table_row: { row_index: rowIndex } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, block: res.data?.block }; } async function insertTableColumn(client, docToken, blockId, columnIndex = -1) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { insert_table_column: { column_index: columnIndex } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, block: res.data?.block }; } async function deleteTableRows(client, docToken, blockId, rowStart, rowCount = 1) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { delete_table_rows: { row_start_index: rowStart, row_end_index: rowStart + rowCount } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, rows_deleted: rowCount, block: res.data?.block }; } async function deleteTableColumns(client, docToken, blockId, columnStart, columnCount = 1) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { delete_table_columns: { column_start_index: columnStart, column_end_index: columnStart + columnCount } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, columns_deleted: columnCount, block: res.data?.block }; } async function mergeTableCells(client, docToken, blockId, rowStart, rowEnd, columnStart, columnEnd) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { merge_table_cells: { row_start_index: rowStart, row_end_index: rowEnd, column_start_index: columnStart, column_end_index: columnEnd } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, block: res.data?.block }; } //#endregion //#region extensions/feishu/src/docx-batch-insert.ts const BATCH_SIZE = 1e3; function normalizeChildIds$1(children) { if (Array.isArray(children)) return children; const child = readStringValue(children); return child ? [child] : void 0; } function toDescendantBlock$1(block) { const children = normalizeChildIds$1(block.children); return { ...block, ...children ? { children } : {} }; } /** * Collect all descendant blocks for a given first-level block ID. * Recursively traverses the block tree to gather all children. */ function collectDescendants(blockMap, rootId) { const result = []; const visited = /* @__PURE__ */ new Set(); function collect(blockId) { if (visited.has(blockId)) return; visited.add(blockId); const block = blockMap.get(blockId); if (!block) return; result.push(block); const children = block.children; if (Array.isArray(children)) for (const childId of children) collect(childId); else if (typeof children === "string") collect(children); } collect(rootId); return result; } /** * Insert a single batch of blocks using Descendant API. * * @param parentBlockId - Parent block to insert into (defaults to docToken) * @param index - Position within parent's children (-1 = end) */ async function insertBatch(client, docToken, blocks, firstLevelBlockIds, parentBlockId = docToken, index = -1) { const descendants = cleanBlocksForDescendant(blocks); if (descendants.length === 0) return []; const res = await client.docx.documentBlockDescendant.create({ path: { document_id: docToken, block_id: parentBlockId }, data: { children_id: firstLevelBlockIds, descendants: descendants.map(toDescendantBlock$1), index } }); if (res.code !== 0) throw new Error(`${res.msg} (code: ${res.code})`); return res.data?.children ?? []; } /** * Insert blocks in batches for large documents (>1000 blocks). * * Batches are split to ensure BOTH children_id AND descendants * arrays stay under the 1000 block API limit. * * @param client - Feishu API client * @param docToken - Document ID * @param blocks - All blocks from Convert API * @param firstLevelBlockIds - IDs of top-level blocks to insert * @param logger - Optional logger for progress updates * @param parentBlockId - Parent block to insert into (defaults to docToken = document root) * @param startIndex - Starting position within parent (-1 = end). For multi-batch inserts, * each batch advances this by the number of first-level IDs inserted so far. * @returns Inserted children blocks and any skipped block IDs */ async function insertBlocksInBatches(client, docToken, blocks, firstLevelBlockIds, logger, parentBlockId = docToken, startIndex = -1) { const allChildren = []; const batches = []; let currentBatch = { firstLevelIds: [], blocks: [] }; const usedBlockIds = /* @__PURE__ */ new Set(); const blockMap = /* @__PURE__ */ new Map(); for (const block of blocks) if (block.block_id) blockMap.set(block.block_id, block); for (const firstLevelId of firstLevelBlockIds) { const newBlocks = collectDescendants(blockMap, firstLevelId).filter((b) => b.block_id && !usedBlockIds.has(b.block_id)); if (newBlocks.length > 1e3) throw new Error(`Block "${firstLevelId}" has ${newBlocks.length} descendants, which exceeds the Feishu API limit of ${BATCH_SIZE} blocks per request. Please split the content into smaller sections.`); if (currentBatch.blocks.length + newBlocks.length > 1e3 && currentBatch.blocks.length > 0) { batches.push(currentBatch); currentBatch = { firstLevelIds: [], blocks: [] }; } currentBatch.firstLevelIds.push(firstLevelId); for (const block of newBlocks) { currentBatch.blocks.push(block); if (block.block_id) usedBlockIds.add(block.block_id); } } if (currentBatch.blocks.length > 0) batches.push(currentBatch); let currentIndex = startIndex; for (let i = 0; i < batches.length; i++) { const batch = batches[i]; logger?.info?.(`feishu_doc: Inserting batch ${i + 1}/${batches.length} (${batch.blocks.length} blocks)...`); const children = await insertBatch(client, docToken, batch.blocks, batch.firstLevelIds, parentBlockId, currentIndex); allChildren.push(...children); if (currentIndex !== -1) currentIndex += batch.firstLevelIds.length; } return { children: allChildren, skipped: [] }; } //#endregion //#region extensions/feishu/src/docx-color-text.ts const TEXT_COLOR = { red: 1, orange: 2, yellow: 3, green: 4, blue: 5, purple: 6, grey: 7, gray: 7 }; const BACKGROUND_COLOR = { red: 1, orange: 2, yellow: 3, green: 4, blue: 5, purple: 6, grey: 7, gray: 7 }; /** * Parse color markup into segments. * * Supports: * [red]text[/red] → red text * [bg:yellow]text[/bg] → yellow background * [bold]text[/bold] → bold * [green bold]text[/green] → green + bold */ function parseColorMarkup(content) { const segments = []; const KNOWN = "(?:bg:[a-z]+|bold|red|orange|yellow|green|blue|purple|gr[ae]y)"; const tagPattern = new RegExp(`\\[(${KNOWN}(?:\\s+${KNOWN})*)\\](.*?)\\[\\/(?:[^\\]]+)\\]|([^[]+|\\[)`, "gis"); let match; while ((match = tagPattern.exec(content)) !== null) if (match[3] !== void 0) { if (match[3]) segments.push({ text: match[3] }); } else { const tagStr = normalizeLowercaseStringOrEmpty(match[1]); const text = match[2]; const tags = tagStr.split(/\s+/); const segment = { text }; for (const tag of tags) if (tag.startsWith("bg:")) { const color = tag.slice(3); if (BACKGROUND_COLOR[color]) segment.bgColor = BACKGROUND_COLOR[color]; } else if (tag === "bold") segment.bold = true; else if (TEXT_COLOR[tag]) segment.textColor = TEXT_COLOR[tag]; if (text) segments.push(segment); } return segments; } /** * Update a text block with colored segments. */ async function updateColorText(client, docToken, blockId, content) { const segments = parseColorMarkup(content); const elements = segments.map((seg) => ({ text_run: { content: seg.text, text_element_style: { ...seg.textColor && { text_color: seg.textColor }, ...seg.bgColor && { background_color: seg.bgColor }, ...seg.bold && { bold: true } } } })); const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { update_text_elements: { elements } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, segments: segments.length, block: res.data?.block }; } //#endregion //#region extensions/feishu/src/docx.ts function json$1(data) { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], details: data }; } function resolveDocToolLocalRoots(ctx) { if (ctx.fsPolicy?.workspaceOnly !== true) return; const workspaceDir = ctx.workspaceDir?.trim(); if (!workspaceDir) return []; return [resolve(workspaceDir)]; } /** Extract image URLs from markdown content */ function extractImageUrls(markdown) { const regex = /!\[[^\]]*\]\(([^)]+)\)/g; const urls = []; 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; } const BLOCK_TYPE_NAMES = { 1: "Page", 2: "Text", 3: "Heading1", 4: "Heading2", 5: "Heading3", 12: "Bullet", 13: "Ordered", 14: "Code", 15: "Quote", 17: "Todo", 18: "Bitable", 21: "Diagram", 22: "Divider", 23: "File", 27: "Image", 30: "Sheet", 31: "Table", 32: "TableCell" }; const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]); /** Clean blocks for insertion (remove unsupported types and read-only fields) */ function cleanBlocksForInsert(blocks) { const skipped = []; return { cleaned: blocks.filter((block) => { if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) { const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`; skipped.push(typeName); return false; } return true; }).map((block) => { if (block.block_type === 31 && block.table?.merge_info) { const { merge_info: _merge_info, ...tableRest } = block.table; return Object.assign({}, block, { table: tableRest }); } return block; }), skipped }; } /** Max blocks per documentBlockChildren.create request */ const MAX_CONVERT_RETRY_DEPTH = 8; async function convertMarkdown(client, markdown) { const res = await client.docx.document.convert({ data: { content_type: "markdown", content: markdown } }); if (res.code !== 0) throw new Error(res.msg); return { blocks: res.data?.blocks ?? [], firstLevelBlockIds: res.data?.first_level_block_ids ?? [] }; } function normalizeChildIds(children) { if (Array.isArray(children)) return children.filter((child) => typeof child === "string"); if (typeof children === "string") return [children]; return []; } function toCreateChildBlock(block) { return block; } function toDescendantBlock(block) { const children = normalizeChildIds(block.children); return { ...block.block_id ? { block_id: block.block_id } : {}, ...children.length > 0 ? { children } : {}, ...block }; } function normalizeInsertedChildBlocks(children) { if (!Array.isArray(children)) return []; return children.filter((child) => typeof child === "object" && child !== null); } function normalizeConvertedBlockTree(blocks, firstLevelIds) { if (blocks.length <= 1) return { orderedBlocks: blocks, rootIds: blocks.length === 1 && typeof blocks[0]?.block_id === "string" ? [blocks[0].block_id] : [] }; const byId = /* @__PURE__ */ new Map(); const originalOrder = /* @__PURE__ */ new Map(); 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 = /* @__PURE__ */ new Set(); for (const block of blocks) for (const childId of normalizeChildIds(block?.children)) childIds.add(childId); const inferredTopLevelIds = blocks.filter((block) => { const blockId = block?.block_id; if (typeof blockId !== "string") return false; const parentId = typeof block?.parent_id === "string" ? block.parent_id : ""; return !childIds.has(blockId) && (!parentId || !byId.has(parentId)); }).toSorted((a, b) => (originalOrder.get(a.block_id ?? "__missing__") ?? 0) - (originalOrder.get(b.block_id ?? "__missing__") ?? 0)).map((block) => block.block_id).filter((blockId) => typeof blockId === "string"); const uniqueRootIds = uniqueStrings((firstLevelIds && firstLevelIds.length > 0 ? firstLevelIds : inferredTopLevelIds).filter((id) => typeof id === "string" && byId.has(id))); const orderedBlocks = []; const visited = /* @__PURE__ */ new Set(); const visit = (blockId) => { 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 uniqueRootIds) visit(rootId); for (const block of blocks) if (typeof block?.block_id === "string") visit(block.block_id); else orderedBlocks.push(block); return { orderedBlocks, rootIds: uniqueRootIds }; } async function insertBlocks(client, docToken, blocks, parentBlockId, index) { const { cleaned, skipped } = cleanBlocksForInsert(blocks); const blockId = parentBlockId ?? docToken; if (cleaned.length === 0) return { children: [], skipped }; const allInserted = []; 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 !== void 0 ? { index: index + offset } : {} } }); if (res.code !== 0) throw new 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) { const lines = markdown.split("\n"); const chunks = []; let current = []; 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, maxChars) { if (markdown.length <= maxChars) return [markdown]; const lines = markdown.split("\n"); const chunks = []; let current = []; 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; 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, markdown, depth = 0) { try { return await convertMarkdown(client, markdown); } catch (error) { if (depth >= MAX_CONVERT_RETRY_DEPTH || markdown.length < 2) throw error; const chunks = splitMarkdownBySize(markdown, Math.max(256, Math.floor(markdown.length / 2))); if (chunks.length <= 1) throw error; const blocks = []; const firstLevelBlockIds = []; for (const chunk of chunks) { const converted = await convertMarkdownWithFallback(client, chunk, depth + 1); blocks.push(...converted.blocks); firstLevelBlockIds.push(...converted.firstLevelBlockIds); } return { blocks, firstLevelBlockIds }; } } /** Convert markdown in chunks to avoid document.convert content size limits */ async function chunkedConvertMarkdown(client, markdown) { const chunks = splitMarkdownByHeadings(markdown); const allBlocks = []; const allRootIds = []; for (const chunk of chunks) { const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk); const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); allBlocks.push(...orderedBlocks); allRootIds.push(...rootIds); } return { blocks: allBlocks, firstLevelBlockIds: allRootIds }; } /** * Insert blocks using the Descendant API (supports tables, nested lists, large docs). * Unlike the Children API, this supports block_type 31/32 (Table/TableCell). * * @param parentBlockId - Parent block to insert into (defaults to docToken = document root) * @param index - Position within parent's children (-1 = end, 0 = first) */ async function insertBlocksWithDescendant(client, docToken, blocks, firstLevelBlockIds, { parentBlockId = docToken, index = -1 } = {}) { const descendants = cleanBlocksForDescendant(blocks); if (descendants.length === 0) return { children: [] }; const res = await client.docx.documentBlockDescendant.create({ path: { document_id: docToken, block_id: parentBlockId }, data: { children_id: firstLevelBlockIds, descendants: descendants.map(toDescendantBlock), index } }); if (res.code !== 0) throw new Error(`${res.msg} (code: ${res.code})`); return { children: res.data?.children ?? [] }; } async function clearDocumentContent(client, docToken) { const existing = await client.docx.documentBlock.list({ path: { document_id: docToken } }); if (existing.code !== 0) throw new Error(existing.msg); const childIds = existing.data?.items?.filter((b) => b.parent_id === docToken && b.block_type !== 1).map((b) => b.block_id) ?? []; if (childIds.length > 0) { const res = await client.docx.documentBlockChildren.batchDelete({ path: { document_id: docToken, block_id: docToken }, data: { start_index: 0, end_index: childIds.length } }); if (res.code !== 0) throw new Error(res.msg); } return childIds.length; } async function uploadImageToDocx(client, blockId, imageBuffer, fileName, docToken) { const fileToken = (await client.drive.media.uploadAll({ data: { file_name: fileName, parent_type: "docx_image", parent_node: blockId, size: imageBuffer.length, file: imageBuffer, ...docToken ? { extra: JSON.stringify({ drive_route_token: docToken }) } : {} } }))?.file_token; if (!fileToken) throw new Error("Image upload failed: no file_token returned"); return fileToken; } async function downloadImage(url, maxBytes) { return (await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ url, maxBytes })).buffer; } async function resolveUploadInput(url, filePath, maxBytes, localRoots, explicitFileName, imageInput) { const inputSources = [ url ? "url" : null, filePath ? "file_path" : null, imageInput ? "image" : null ].filter(Boolean); if (inputSources.length > 1) throw new Error(`Provide only one image source; got: ${inputSources.join(", ")}`); 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); if (!header.includes(";base64")) throw new Error("Invalid data URI: missing ';base64' marker. Expected format: data:image/png;base64,"); 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 ext = extensionForMime(header.match(/data:([^;]+)/)?.[1])?.slice(1) ?? "png"; const estimatedBytes = Math.ceil(trimmedData.length * 3 / 4); if (estimatedBytes > maxBytes) throw new Error(`Image data URI exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`); return { buffer: Buffer.from(trimmedData, "base64"), fileName: explicitFileName ?? `image.${ext}` }; } 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)) { const resolvedPath = resolve(candidate); return { buffer: (await getFeishuRuntime().media.loadWebMedia(resolvedPath, { maxBytes, optimizeImages: false, localRoots })).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,...`); } if (imageInput) { const trimmed = imageInput.trim(); 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."); 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 guessed = new URL(url).pathname.split("/").pop() || "upload.bin"; return { buffer: fetched.buffer, fileName: explicitFileName || guessed }; } const resolvedFilePath = resolve(filePath); return { buffer: (await getFeishuRuntime().media.loadWebMedia(resolvedFilePath, { maxBytes, optimizeImages: false, localRoots })).buffer, fileName: explicitFileName || basename(filePath) }; } async function processImages(client, docToken, markdown, insertedBlocks, maxBytes) { const imageUrls = extractImageUrls(markdown); if (imageUrls.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]; const blockId = imageBlocks[i]?.block_id; if (!blockId) continue; try { const fileToken = await uploadImageToDocx(client, blockId, await downloadImage(url, maxBytes), new URL(url).pathname.split("/").pop() || `image_${i}.png`, docToken); await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { replace_image: { token: fileToken } } }); processed++; } catch (err) { console.error(`Failed to process image ${url}:`, err); } } return processed; } async function uploadImageBlock(client, docToken, maxBytes, localRoots, url, filePath, parentBlockId, filename, index, imageInput) { const insertRes = await client.docx.documentBlockChildren.create({ path: { document_id: docToken, block_id: parentBlockId ?? docToken }, params: { document_revision_id: -1 }, data: { children: [{ block_type: 27, image: {} }], index: index ?? -1 } }); if (insertRes.code !== 0) throw new Error(`Failed to create image block: ${insertRes.msg}`); const imageBlockId = insertRes.data?.children?.find((b) => b.block_type === 27)?.block_id; if (!imageBlockId) throw new Error("Failed to create image block"); const upload = await resolveUploadInput(url, filePath, maxBytes, localRoots, filename, imageInput); const fileToken = await uploadImageToDocx(client, imageBlockId, upload.buffer, upload.fileName, docToken); const patchRes = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: imageBlockId }, data: { replace_image: { token: fileToken } } }); if (patchRes.code !== 0) throw new Error(patchRes.msg); return { success: true, block_id: imageBlockId, file_token: fileToken, file_name: upload.fileName, size: upload.buffer.length }; } async function uploadFileBlock(client, docToken, maxBytes, localRoots, url, filePath, parentBlockId, filename) { const blockId = parentBlockId ?? docToken; const upload = await resolveUploadInput(url, filePath, maxBytes, localRoots, filename); const converted = await convertMarkdown(client, `[${upload.fileName}](https://example.com/placeholder)`); const { orderedBlocks } = normalizeConvertedBlockTree(converted.blocks, converted.firstLevelBlockIds); const { children: inserted } = await insertBlocks(client, docToken, orderedBlocks, blockId); const placeholderBlock = inserted[0]; if (!placeholderBlock?.block_id) throw new Error("Failed to create placeholder block for file upload"); const parentId = placeholderBlock.parent_id ?? blockId; const childrenRes = await client.docx.documentBlockChildren.get({ path: { document_id: docToken, block_id: parentId } }); if (childrenRes.code !== 0) throw new Error(childrenRes.msg); const placeholderIdx = (childrenRes.data?.items ?? []).findIndex((item) => item.block_id === placeholderBlock.block_id); if (placeholderIdx >= 0) { const deleteRes = await client.docx.documentBlockChildren.batchDelete({ path: { document_id: docToken, block_id: parentId }, data: { start_index: placeholderIdx, end_index: placeholderIdx + 1 } }); if (deleteRes.code !== 0) throw new Error(deleteRes.msg); } const fileToken = (await client.drive.media.uploadAll({ data: { file_name: upload.fileName, parent_type: "docx_file", parent_node: docToken, size: upload.buffer.length, file: upload.buffer } }))?.file_token; if (!fileToken) throw new 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." }; } const STRUCTURED_BLOCK_TYPES = new Set([ 14, 18, 21, 23, 27, 30, 31, 32 ]); async function readDoc(client, docToken) { const [contentRes, infoRes, blocksRes] = await Promise.all([ client.docx.document.rawContent({ path: { document_id: docToken } }), client.docx.document.get({ path: { document_id: docToken } }), client.docx.documentBlock.list({ path: { document_id: docToken } }) ]); if (contentRes.code !== 0) throw new Error(contentRes.msg); const blocks = blocksRes.data?.items ?? []; const blockCounts = {}; const structuredTypes = []; 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; 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.`; return { title: infoRes.data?.document?.title, content: contentRes.data?.content, revision_id: infoRes.data?.document?.revision_id, block_count: blocks.length, block_types: blockCounts, ...hint && { hint } }; } async function createDoc(client, title, folderToken, options) { const res = await client.docx.document.create({ data: { title, folder_token: folderToken } }); if (res.code !== 0) throw new Error(res.msg); const doc = res.data?.document; const docToken = doc?.document_id; if (!docToken) throw new Error("Document creation succeeded but no document_id was returned"); const shouldGrantToRequester = options?.grantToRequester !== false; const requesterOpenId = options?.requesterOpenId?.trim(); const requesterPermType = "edit"; let requesterPermissionAdded = false; let requesterPermissionSkippedReason; let requesterPermissionError; if (shouldGrantToRequester) if (!requesterOpenId) requesterPermissionSkippedReason = "trusted requester identity unavailable"; else try { await client.drive.permissionMember.create({ path: { token: docToken }, params: { type: "docx", need_notification: false }, data: { member_type: "openid", member_id: requesterOpenId, perm: requesterPermType } }); requesterPermissionAdded = true; } catch (err) { requesterPermissionError = formatErrorMessage(err); } return { document_id: docToken, title: doc?.title, url: `https://feishu.cn/docx/${docToken}`, ...shouldGrantToRequester && { requester_permission_added: requesterPermissionAdded, ...requesterOpenId && { requester_open_id: requesterOpenId }, requester_perm_type: requesterPermType, ...requesterPermissionSkippedReason && { requester_permission_skipped_reason: requesterPermissionSkippedReason }, ...requesterPermissionError && { requester_permission_error: requesterPermissionError } } }; } async function writeDoc(client, docToken, markdown, maxBytes, logger) { const deleted = await clearDocumentContent(client, docToken); logger?.info?.("feishu_doc: Converting markdown..."); const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); if (blocks.length === 0) return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 }; logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`); const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); const { children: inserted } = blocks.length > 1e3 ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger) : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds); const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); return { success: true, blocks_deleted: deleted, blocks_added: blocks.length, images_processed: imagesProcessed }; } async function appendDoc(client, docToken, markdown, maxBytes, logger) { logger?.info?.("feishu_doc: Converting markdown..."); const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); if (blocks.length === 0) throw new Error("Content is empty"); logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`); const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); const { children: inserted } = blocks.length > 1e3 ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger) : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds); const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); return { success: true, blocks_added: blocks.length, images_processed: imagesProcessed, block_ids: inserted.map((b) => b.block_id) }; } async function insertDoc(client, docToken, markdown, afterBlockId, maxBytes, logger) { const blockInfo = await client.docx.documentBlock.get({ path: { document_id: docToken, block_id: afterBlockId } }); if (blockInfo.code !== 0) throw new Error(blockInfo.msg); const parentId = blockInfo.data?.block?.parent_id ?? docToken; const items = []; let pageToken; do { const childrenRes = await client.docx.documentBlockChildren.get({ path: { document_id: docToken, block_id: parentId }, params: pageToken ? { page_token: pageToken } : {} }); if (childrenRes.code !== 0) throw new Error(childrenRes.msg); items.push(...childrenRes.data?.items ?? []); pageToken = childrenRes.data?.page_token ?? void 0; } while (pageToken); const blockIndex = items.findIndex((item) => item.block_id === afterBlockId); if (blockIndex === -1) throw new 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; logger?.info?.("feishu_doc: Converting markdown..."); const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); if (blocks.length === 0) throw new Error("Content is empty"); const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting at index ${insertIndex}...`); const { children: inserted } = blocks.length > 1e3 ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger, parentId, insertIndex) : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds, { parentBlockId: parentId, index: insertIndex }); const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); return { success: true, blocks_added: blocks.length, images_processed: imagesProcessed, block_ids: inserted.map((b) => b.block_id) }; } async function createTable(client, docToken, rowSize, columnSize, parentBlockId, columnWidth) { if (columnWidth && columnWidth.length !== columnSize) throw new Error("column_width length must equal column_size"); const blockId = parentBlockId ?? docToken; const res = await client.docx.documentBlockChildren.create({ path: { document_id: docToken, block_id: blockId }, data: { children: [{ block_type: 31, table: { property: { row_size: rowSize, column_size: columnSize, ...columnWidth && columnWidth.length > 0 ? { column_width: columnWidth } : {} } } }] } }); if (res.code !== 0) throw new Error(res.msg); const tableBlock = res.data?.children?.find((b) => b.block_type === 31); const cells = normalizeInsertedChildBlocks(tableBlock?.children); return { success: true, table_block_id: tableBlock?.block_id, row_size: rowSize, column_size: columnSize, table_cell_block_ids: cells.map((c) => c.block_id).filter(Boolean), raw_children_count: res.data?.children?.length ?? 0 }; } async function writeTableCells(client, docToken, tableBlockId, values) { if (!values.length || !values[0]?.length) throw new Error("values must be a non-empty 2D array"); const tableRes = await client.docx.documentBlock.get({ path: { document_id: docToken, block_id: tableBlockId } }); if (tableRes.code !== 0) throw new Error(tableRes.msg); const tableBlock = tableRes.data?.block; if (tableBlock?.block_type !== 31) throw new Error("table_block_id is not a table block"); const tableData = tableBlock.table; const rows = tableData?.property?.row_size; const cols = tableData?.property?.column_size; const cellIds = tableData?.cells ?? []; if (!rows || !cols || !cellIds.length) throw new Error("Table cell IDs unavailable from table block. Use list_blocks/get_block and pass explicit cell block IDs if needed."); const writeRows = Math.min(values.length, rows); let written = 0; for (let r = 0; r < writeRows; r++) { const rowValues = values[r] ?? []; const writeCols = Math.min(rowValues.length, cols); for (let c = 0; c < writeCols; c++) { const cellId = cellIds[r * cols + c]; if (!cellId) continue; const childrenRes = await client.docx.documentBlockChildren.get({ path: { document_id: docToken, block_id: cellId } }); if (childrenRes.code !== 0) throw new Error(childrenRes.msg); const existingChildren = childrenRes.data?.items ?? []; if (existingChildren.length > 0) { const delRes = await client.docx.documentBlockChildren.batchDelete({ path: { document_id: docToken, block_id: cellId }, data: { start_index: 0, end_index: existingChildren.length } }); if (delRes.code !== 0) throw new Error(delRes.msg); } const converted = await convertMarkdown(client, rowValues[c] ?? ""); const { orderedBlocks } = normalizeConvertedBlockTree(converted.blocks, converted.firstLevelBlockIds); if (orderedBlocks.length > 0) await insertBlocks(client, docToken, orderedBlocks, cellId); written++; } } return { success: true, table_block_id: tableBlockId, cells_written: written, table_size: { rows, cols } }; } async function createTableWithValues(client, docToken, rowSize, columnSize, values, parentBlockId, columnWidth) { const tableBlockId = (await createTable(client, docToken, rowSize, columnSize, parentBlockId, columnWidth)).table_block_id; if (!tableBlockId) throw new Error("create_table succeeded but table_block_id is missing"); return { success: true, table_block_id: tableBlockId, row_size: rowSize, column_size: columnSize, cells_written: (await writeTableCells(client, docToken, tableBlockId, values)).cells_written }; } async function updateBlock(client, docToken, blockId, content) { const blockInfo = await client.docx.documentBlock.get({ path: { document_id: docToken, block_id: blockId } }); if (blockInfo.code !== 0) throw new Error(blockInfo.msg); const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { update_text_elements: { elements: [{ text_run: { content } }] } } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, block_id: blockId }; } async function deleteBlock(client, docToken, blockId) { const blockInfo = await client.docx.documentBlock.get({ path: { document_id: docToken, block_id: blockId } }); if (blockInfo.code !== 0) throw new Error(blockInfo.msg); const parentId = blockInfo.data?.block?.parent_id ?? docToken; const children = await client.docx.documentBlockChildren.get({ path: { document_id: docToken, block_id: parentId } }); if (children.code !== 0) throw new Error(children.msg); const index = (children.data?.items ?? []).findIndex((item) => item.block_id === blockId); if (index === -1) throw new Error("Block not found"); const res = await client.docx.documentBlockChildren.batchDelete({ path: { document_id: docToken, block_id: parentId }, data: { start_index: index, end_index: index + 1 } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, deleted_block_id: blockId }; } async function listBlocks(client, docToken) { const res = await client.docx.documentBlock.list({ path: { document_id: docToken } }); if (res.code !== 0) throw new Error(res.msg); return { blocks: res.data?.items ?? [] }; } async function getBlock(client, docToken, blockId) { const res = await client.docx.documentBlock.get({ path: { document_id: docToken, block_id: blockId } }); if (res.code !== 0) throw new Error(res.msg); return { block: res.data?.block }; } async function listAppScopes(client) { const res = await client.application.scope.list({}); if (res.code !== 0) throw new Error(res.msg); const scopes = res.data?.scopes ?? []; const granted = scopes.filter((s) => s.grant_status === 1); const pending = scopes.filter((s) => s.grant_status !== 1); return { granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })), pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })), summary: `${granted.length} granted, ${pending.length} pending` }; } function registerFeishuDocTools(api) { if (!api.config) return; const accounts = listEnabledFeishuAccounts(api.config); if (accounts.length === 0) return; const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); const registered = []; const getClient = (params, defaultAccountId) => createFeishuToolClient({ api, executeParams: params, defaultAccountId, requiredTool: { family: "doc", label: "Doc" } }); const getMediaMaxBytes = (params, defaultAccountId) => (resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId, requiredTool: { family: "doc", label: "Doc" } }).config?.mediaMaxMb ?? 30) * 1024 * 1024; if (toolsCfg.doc) { api.registerTool((ctx) => { const defaultAccountId = ctx.agentAccountId; const mediaLocalRoots = resolveDocToolLocalRoots(ctx); const trustedRequesterOpenId = ctx.messageChannel === "feishu" ? normalizeOptionalString(ctx.requesterSenderId) : void 0; return { name: "feishu_doc", label: "Feishu Doc", description: "Feishu document operations. Actions: read, write, append, insert, create, list_blocks, get_block, update_block, delete_block, create_table, write_table_cells, create_table_with_values, insert_table_row, insert_table_column, delete_table_rows, delete_table_columns, merge_table_cells, upload_image, upload_file, color_text", parameters: FeishuDocSchema, async execute(_toolCallId, params) { const p = params; try { const client = getClient(p, defaultAccountId); switch (p.action) { case "read": return json$1(await readDoc(client, p.doc_token)); case "write": return json$1(await writeDoc(client, p.doc_token, p.content, getMediaMaxBytes(p, defaultAccountId), api.logger)); case "append": return json$1(await appendDoc(client, p.doc_token, p.content, getMediaMaxBytes(p, defaultAccountId), api.logger)); case "insert": return json$1(await insertDoc(client, p.doc_token, p.content, p.after_block_id, getMediaMaxBytes(p, defaultAccountId), api.logger)); case "create": return json$1(await createDoc(client, p.title, p.folder_token, { grantToRequester: p.grant_to_requester, requesterOpenId: trustedRequesterOpenId })); case "list_blocks": return json$1(await listBlocks(client, p.doc_token)); case "get_block": return json$1(await getBlock(client, p.doc_token, p.block_id)); case "update_block": return json$1(await updateBlock(client, p.doc_token, p.block_id, p.content)); case "delete_block": return json$1(await deleteBlock(client, p.doc_token, p.block_id)); case "create_table": return json$1(await createTable(client, p.doc_token, p.row_size, p.column_size, p.parent_block_id, p.column_width)); case "write_table_cells": return json$1(await writeTableCells(client, p.doc_token, p.table_block_id, p.values)); case "create_table_with_values": return json$1(await createTableWithValues(client, p.doc_token, p.row_size, p.column_size, p.values, p.parent_block_id, p.column_width)); case "upload_image": return json$1(await uploadImageBlock(client, p.doc_token, getMediaMaxBytes(p, defaultAccountId), mediaLocalRoots, p.url, p.file_path, p.parent_block_id, p.filename, p.index, p.image)); case "upload_file": return json$1(await uploadFileBlock(client, p.doc_token, getMediaMaxBytes(p, defaultAccountId), mediaLocalRoots, p.url, p.file_path, p.parent_block_id, p.filename)); case "color_text": return json$1(await updateColorText(client, p.doc_token, p.block_id, p.content)); case "insert_table_row": return json$1(await insertTableRow(client, p.doc_token, p.block_id, p.row_index)); case "insert_table_column": return json$1(await insertTableColumn(client, p.doc_token, p.block_id, p.column_index)); case "delete_table_rows": return json$1(await deleteTableRows(client, p.doc_token, p.block_id, p.row_start, p.row_count)); case "delete_table_columns": return json$1(await deleteTableColumns(client, p.doc_token, p.block_id, p.column_start, p.column_count)); case "merge_table_cells": return json$1(await mergeTableCells(client, p.doc_token, p.block_id, p.row_start, p.row_end, p.column_start, p.column_end)); default: return json$1({ error: "Unknown action" }); } } catch (err) { return json$1({ error: formatErrorMessage(err) }); } } }; }, { name: "feishu_doc" }); registered.push("feishu_doc"); } if (toolsCfg.scopes) { api.registerTool((ctx) => ({ name: "feishu_app_scopes", label: "Feishu App Scopes", description: "List current app permissions (scopes). Use to debug permission issues or check available capabilities.", parameters: Type.Object({}), async execute() { try { return json$1(await listAppScopes(createFeishuToolClient({ api, defaultAccountId: ctx.agentAccountId, requiredTool: { family: "scopes", label: "App Scopes" } }))); } catch (err) { return json$1({ error: formatErrorMessage(err) }); } } }), { name: "feishu_app_scopes" }); registered.push("feishu_app_scopes"); } } //#endregion //#region extensions/feishu/src/wiki-schema.ts const WIKI_SPACE_ID_DESCRIPTION = "Knowledge space ID. Treat as an opaque string and keep it quoted; never pass numeric-looking IDs as numbers."; const FeishuWikiSchema = Type.Union([ Type.Object({ action: Type.Literal("spaces"), page_size: optionalPositiveIntegerSchema({ maximum: 50, description: "Page size (1-50, default 50)" }), page_token: Type.Optional(Type.String({ description: "Pagination token" })) }), Type.Object({ action: Type.Literal("nodes"), space_id: Type.String({ description: WIKI_SPACE_ID_DESCRIPTION }), parent_node_token: Type.Optional(Type.String({ description: "Parent node token (optional, omit for root)" })), page_size: optionalPositiveIntegerSchema({ maximum: 50, description: "Page size (1-50, default 50)" }), page_token: Type.Optional(Type.String({ description: "Pagination token" })) }), Type.Object({ action: Type.Literal("get"), token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" }) }), Type.Object({ action: Type.Literal("search"), query: Type.String({ description: "Search query" }), space_id: Type.Optional(Type.String({ description: "Limit search to this knowledge space. Treat as an opaque string and keep it quoted; never pass numeric-looking IDs as numbers." })) }), Type.Object({ action: Type.Literal("create"), space_id: Type.String({ description: WIKI_SPACE_ID_DESCRIPTION }), title: Type.String({ description: "Node title" }), obj_type: Type.Optional(Type.Union([ Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable") ], { description: "Object type (default: docx)" })), parent_node_token: Type.Optional(Type.String({ description: "Parent node token (optional, omit for root)" })) }), Type.Object({ action: Type.Literal("move"), space_id: Type.String({ description: "Source knowledge space ID. Treat as an opaque string and keep it quoted; never pass numeric-looking IDs as numbers." }), node_token: Type.String({ description: "Node token to move" }), target_space_id: Type.Optional(Type.String({ description: "Target knowledge space ID (optional, same space if omitted). Treat as an opaque string and keep it quoted; never pass numeric-looking IDs as numbers." })), target_parent_token: Type.Optional(Type.String({ description: "Target parent node token (optional, root if omitted)" })) }), Type.Object({ action: Type.Literal("rename"), space_id: Type.String({ description: WIKI_SPACE_ID_DESCRIPTION }), node_token: Type.String({ description: "Node token to rename" }), title: Type.String({ description: "New title" }) }) ]); //#endregion //#region extensions/feishu/src/wiki.ts const WIKI_PAGE_SIZE = 50; const WIKI_ACCESS_HINT = "To grant wiki access: Open wiki space → Settings → Members → Add the bot. See: https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa#a40ad4ca"; function requireWikiSpaceId(value, fieldName) { if (typeof value !== "string") throw new Error(`${fieldName} must be a string. Feishu wiki space IDs are opaque identifiers; pass them quoted to avoid JavaScript number precision loss.`); const trimmed = value.trim(); if (!trimmed) throw new Error(`${fieldName} must not be empty.`); return trimmed; } function optionalWikiSpaceId(value, fieldName) { if (value === void 0 || value === null || value === "") return; return requireWikiSpaceId(value, fieldName); } function readWikiPageSize(params) { return readPositiveIntegerParam(params, "page_size", { max: WIKI_PAGE_SIZE, message: "page_size must be a positive integer between 1 and 50" }) ?? WIKI_PAGE_SIZE; } async function listSpaces(client, pageSize, pageToken) { const res = await client.wiki.space.list({ params: { page_size: pageSize, page_token: pageToken } }); if (res.code !== 0) throw new Error(res.msg); const spaces = res.data?.items?.map((s) => ({ space_id: s.space_id, name: s.name, description: s.description, visibility: s.visibility })) ?? []; return { spaces, has_more: res.data?.has_more ?? false, page_token: res.data?.page_token, ...spaces.length === 0 && pageToken === void 0 && res.data?.has_more !== true && { hint: WIKI_ACCESS_HINT } }; } async function listNodes(client, spaceId, parentNodeToken, pageSize, pageToken) { const res = await client.wiki.spaceNode.list({ path: { space_id: spaceId }, params: { parent_node_token: parentNodeToken, page_size: pageSize, page_token: pageToken } }); if (res.code !== 0) throw new Error(res.msg); return { nodes: res.data?.items?.map((n) => ({ node_token: n.node_token, obj_token: n.obj_token, obj_type: n.obj_type, title: n.title, has_child: n.has_child })) ?? [], has_more: res.data?.has_more ?? false, page_token: res.data?.page_token }; } async function getNode(client, token) { const res = await client.wiki.space.getNode({ params: { token } }); if (res.code !== 0) throw new Error(res.msg); const node = res.data?.node; return { node_token: node?.node_token, space_id: node?.space_id, obj_token: node?.obj_token, obj_type: node?.obj_type, title: node?.title, parent_node_token: node?.parent_node_token, has_child: node?.has_child, creator: node?.creator, create_time: node?.node_create_time }; } async function createNode(client, spaceId, title, objType, parentNodeToken) { const res = await client.wiki.spaceNode.create({ path: { space_id: spaceId }, data: { obj_type: objType || "docx", node_type: "origin", title, parent_node_token: parentNodeToken } }); if (res.code !== 0) throw new Error(res.msg); const node = res.data?.node; return { node_token: node?.node_token, obj_token: node?.obj_token, obj_type: node?.obj_type, title: node?.title }; } async function moveNode(client, spaceId, nodeToken, targetSpaceId, targetParentToken) { const res = await client.wiki.spaceNode.move({ path: { space_id: spaceId, node_token: nodeToken }, data: { target_space_id: targetSpaceId || spaceId, target_parent_token: targetParentToken } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, node_token: res.data?.node?.node_token }; } async function renameNode(client, spaceId, nodeToken, title) { const res = await client.wiki.spaceNode.updateTitle({ path: { space_id: spaceId, node_token: nodeToken }, data: { title } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, node_token: nodeToken, title }; } function registerFeishuWikiTools(api) { if (!api.config) return; const accounts = listEnabledFeishuAccounts(api.config); if (accounts.length === 0) return; if (!resolveAnyEnabledFeishuToolsConfig(accounts).wiki) return; api.registerTool((ctx) => { const defaultAccountId = ctx.agentAccountId; return { name: "feishu_wiki", label: "Feishu Wiki", description: "Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename", parameters: FeishuWikiSchema, async execute(_toolCallId, params) { const p = params; try { const createClient = () => createFeishuToolClient({ api, executeParams: p, defaultAccountId, requiredTool: { family: "wiki", label: "Wiki" } }); switch (p.action) { case "spaces": return jsonToolResult(await listSpaces(createClient(), readWikiPageSize(p), p.page_token)); case "nodes": { const spaceId = requireWikiSpaceId(p.space_id, "space_id"); return jsonToolResult(await listNodes(createClient(), spaceId, p.parent_node_token, readWikiPageSize(p), p.page_token)); } case "get": return jsonToolResult(await getNode(createClient(), p.token)); case "search": optionalWikiSpaceId(p.space_id, "space_id"); createClient(); return jsonToolResult({ error: "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token." }); case "create": { const spaceId = requireWikiSpaceId(p.space_id, "space_id"); return jsonToolResult(await createNode(createClient(), spaceId, p.title, p.obj_type, p.parent_node_token)); } case "move": { const spaceId = requireWikiSpaceId(p.space_id, "space_id"); return jsonToolResult(await moveNode(createClient(), spaceId, p.node_token, optionalWikiSpaceId(p.target_space_id, "target_space_id"), p.target_parent_token)); } case "rename": { const spaceId = requireWikiSpaceId(p.space_id, "space_id"); return jsonToolResult(await renameNode(createClient(), spaceId, p.node_token, p.title)); } default: return unknownToolActionResult(p.action); } } catch (err) { return toolExecutionErrorResult(err); } } }; }, { name: "feishu_wiki" }); } //#endregion //#region extensions/feishu/src/perm-schema.ts const TokenType = Type.Union([ Type.Literal("doc"), Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable"), Type.Literal("folder"), Type.Literal("file"), Type.Literal("wiki"), Type.Literal("mindnote") ]); const MemberType = Type.Union([ Type.Literal("email"), Type.Literal("openid"), Type.Literal("userid"), Type.Literal("unionid"), Type.Literal("openchat"), Type.Literal("opendepartmentid") ]); const Permission = Type.Union([ Type.Literal("view"), Type.Literal("edit"), Type.Literal("full_access") ]); const FeishuPermSchema = Type.Union([ Type.Object({ action: Type.Literal("list"), token: Type.String({ description: "File token" }), type: TokenType }), Type.Object({ action: Type.Literal("add"), token: Type.String({ description: "File token" }), type: TokenType, member_type: MemberType, member_id: Type.String({ description: "Member ID (email, open_id, user_id, etc.)" }), perm: Permission }), Type.Object({ action: Type.Literal("remove"), token: Type.String({ description: "File token" }), type: TokenType, member_type: MemberType, member_id: Type.String({ description: "Member ID to remove" }) }) ]); //#endregion //#region extensions/feishu/src/perm.ts async function listMembers(client, token, type) { const res = await client.drive.permissionMember.list({ path: { token }, params: { type } }); if (res.code !== 0) throw new Error(res.msg); return { members: res.data?.items?.map((m) => ({ member_type: m.member_type, member_id: m.member_id, perm: m.perm, name: m.name })) ?? [] }; } async function addMember(client, token, type, memberType, memberId, perm) { const res = await client.drive.permissionMember.create({ path: { token }, params: { type, need_notification: false }, data: { member_type: memberType, member_id: memberId, perm } }); if (res.code !== 0) throw new Error(res.msg); return { success: true, member: res.data?.member }; } async function removeMember(client, token, type, memberType, memberId) { const res = await client.drive.permissionMember.delete({ path: { token, member_id: memberId }, params: { type, member_type: memberType } }); if (res.code !== 0) throw new Error(res.msg); return { success: true }; } function registerFeishuPermTools(api) { if (!api.config) return; const accounts = listEnabledFeishuAccounts(api.config); if (accounts.length === 0) return; if (!resolveAnyEnabledFeishuToolsConfig(accounts).perm) return; api.registerTool((ctx) => { const defaultAccountId = ctx.agentAccountId; return { name: "feishu_perm", label: "Feishu Perm", description: "Feishu permission management. Actions: list, add, remove", parameters: FeishuPermSchema, async execute(_toolCallId, params) { const p = params; try { const client = createFeishuToolClient({ api, executeParams: p, defaultAccountId, requiredTool: { family: "perm", label: "Perm" } }); switch (p.action) { case "list": return jsonToolResult(await listMembers(client, p.token, p.type)); case "add": return jsonToolResult(await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm)); case "remove": return jsonToolResult(await removeMember(client, p.token, p.type, p.member_type, p.member_id)); default: return unknownToolActionResult(p.action); } } catch (err) { return toolExecutionErrorResult(err); } } }; }, { name: "feishu_perm" }); } //#endregion //#region extensions/feishu/src/bitable.ts function json(data) { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], details: data }; } var LarkApiError = class extends Error { constructor(code, message, api, context) { super(`[${api}] code=${code} message=${message}`); this.name = "LarkApiError"; this.code = code; this.api = api; this.context = context; } }; function ensureLarkSuccess(res, api, context) { if (res.code !== 0) throw new LarkApiError(res.code ?? -1, res.msg ?? "unknown error", api, context); } /** Field type ID to human-readable name */ const FIELD_TYPE_NAMES = { 1: "Text", 2: "Number", 3: "SingleSelect", 4: "MultiSelect", 5: "DateTime", 7: "Checkbox", 11: "User", 13: "Phone", 15: "URL", 17: "Attachment", 18: "SingleLink", 19: "Lookup", 20: "Formula", 21: "DuplexLink", 22: "Location", 23: "GroupChat", 1001: "CreatedTime", 1002: "ModifiedTime", 1003: "CreatedUser", 1004: "ModifiedUser", 1005: "AutoNumber" }; /** Parse bitable URL and extract tokens */ function parseBitableUrl(url) { try { const u = new URL(url); const tableId = u.searchParams.get("table") ?? void 0; const wikiMatch = u.pathname.match(/\/wiki\/([A-Za-z0-9]+)/); if (wikiMatch) return { token: wikiMatch[1], tableId, isWiki: true }; const baseMatch = u.pathname.match(/\/base\/([A-Za-z0-9]+)/); if (baseMatch) return { token: baseMatch[1], tableId, isWiki: false }; return null; } catch { return null; } } /** Get app_token from wiki node_token */ async function getAppTokenFromWiki(client, nodeToken) { const res = await client.wiki.space.getNode({ params: { token: nodeToken } }); ensureLarkSuccess(res, "wiki.space.getNode", { nodeToken }); const node = res.data?.node; if (!node) throw new Error("Node not found"); if (node.obj_type !== "bitable") throw new Error(`Node is not a bitable (type: ${node.obj_type})`); return node.obj_token; } /** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */ async function getBitableMeta(client, url) { const parsed = parseBitableUrl(url); if (!parsed) throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL"); let appToken; if (parsed.isWiki) appToken = await getAppTokenFromWiki(client, parsed.token); else appToken = parsed.token; const res = await client.bitable.app.get({ path: { app_token: appToken } }); ensureLarkSuccess(res, "bitable.app.get", { appToken }); let tables = []; if (!parsed.tableId) { const tablesRes = await client.bitable.appTable.list({ path: { app_token: appToken } }); if (tablesRes.code === 0) tables = (tablesRes.data?.items ?? []).map((t) => ({ table_id: t.table_id, name: t.name })); } return { app_token: appToken, table_id: parsed.tableId, name: res.data?.app?.name, url_type: parsed.isWiki ? "wiki" : "base", ...tables.length > 0 && { tables }, hint: parsed.tableId ? `Use app_token="${appToken}" and table_id="${parsed.tableId}" for other bitable tools` : `Use app_token="${appToken}" for other bitable tools. Select a table_id from the tables list.` }; } async function listFields(client, appToken, tableId) { const res = await client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } }); ensureLarkSuccess(res, "bitable.appTableField.list", { appToken, tableId }); const fields = res.data?.items ?? []; return { fields: fields.map((f) => ({ field_id: f.field_id, field_name: f.field_name, type: f.type, type_name: FIELD_TYPE_NAMES[f.type ?? 0] || `type_${f.type}`, is_primary: f.is_primary, ...f.property && { property: f.property } })), total: fields.length }; } async function listRecords(client, appToken, tableId, pageSize, pageToken) { const res = await client.bitable.appTableRecord.list({ path: { app_token: appToken, table_id: tableId }, params: { page_size: pageSize ?? 100, ...pageToken && { page_token: pageToken } } }); ensureLarkSuccess(res, "bitable.appTableRecord.list", { appToken, tableId, pageSize }); return { records: res.data?.items ?? [], has_more: res.data?.has_more ?? false, page_token: res.data?.page_token, total: res.data?.total }; } function readBitableListRecordsPageSize(params) { return readPositiveIntegerParam(params, "page_size", { max: 500, message: "page_size must be a positive integer between 1 and 500" }); } async function getRecord(client, appToken, tableId, recordId) { const res = await client.bitable.appTableRecord.get({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }); ensureLarkSuccess(res, "bitable.appTableRecord.get", { appToken, tableId, recordId }); return { record: res.data?.record }; } async function createRecord(client, appToken, tableId, fields) { const res = await client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }); ensureLarkSuccess(res, "bitable.appTableRecord.create", { appToken, tableId }); return { record: res.data?.record }; } /** Default field types created for new Bitable tables (to be cleaned up) */ const DEFAULT_CLEANUP_FIELD_TYPES = new Set([ 3, 5, 17 ]); function isDefaultEmptyBitableFieldValue(value) { if (value === void 0 || value === null || value === "") return true; if (Array.isArray(value)) return value.every(isDefaultEmptyBitableFieldValue); if (typeof value === "object") { const record = value; const keys = Object.keys(record); if (keys.length === 0) return true; if ("text" in record && keys.every((key) => key === "text" || key === "type")) return record.text === void 0 || record.text === null || record.text === ""; return Object.values(record).every(isDefaultEmptyBitableFieldValue); } return false; } function isPlaceholderBitableRecord(fields) { if (!fields || typeof fields !== "object" || Array.isArray(fields)) return true; return Object.values(fields).every(isDefaultEmptyBitableFieldValue); } /** Clean up default placeholder rows and fields in a newly created Bitable table */ async function cleanupNewBitable(client, appToken, tableId, tableName, logger) { let cleanedRows = 0; let cleanedFields = 0; const fieldsRes = await client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } }); if (fieldsRes.code === 0 && fieldsRes.data?.items) { const primaryField = fieldsRes.data.items.find((f) => f.is_primary); if (primaryField?.field_id) try { const newFieldName = tableName.length <= 20 ? tableName : "Name"; await client.bitable.appTableField.update({ path: { app_token: appToken, table_id: tableId, field_id: primaryField.field_id }, data: { field_name: newFieldName, type: 1 } }); cleanedFields++; } catch (err) { logger.debug(`Failed to rename primary field: ${String(err)}`); } const defaultFieldsToDelete = fieldsRes.data.items.filter((f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0)); for (const field of defaultFieldsToDelete) if (field.field_id) try { await client.bitable.appTableField.delete({ path: { app_token: appToken, table_id: tableId, field_id: field.field_id } }); cleanedFields++; } catch (err) { logger.debug(`Failed to delete default field ${field.field_name}: ${String(err)}`); } } const recordsRes = await client.bitable.appTableRecord.list({ path: { app_token: appToken, table_id: tableId }, params: { page_size: 100 } }); if (recordsRes.code === 0 && recordsRes.data?.items) { const emptyRecordIds = recordsRes.data.items.filter((r) => isPlaceholderBitableRecord(r.fields)).map((r) => r.record_id).filter((id) => Boolean(id)); if (emptyRecordIds.length > 0) try { await client.bitable.appTableRecord.batchDelete({ path: { app_token: appToken, table_id: tableId }, data: { records: emptyRecordIds } }); cleanedRows = emptyRecordIds.length; } catch { for (const recordId of emptyRecordIds) try { await client.bitable.appTableRecord.delete({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }); cleanedRows++; } catch (err) { logger.debug(`Failed to delete empty row ${recordId}: ${String(err)}`); } } } return { cleanedRows, cleanedFields }; } async function createApp(client, name, folderToken, logger) { const res = await client.bitable.app.create({ data: { name, ...folderToken && { folder_token: folderToken } } }); ensureLarkSuccess(res, "bitable.app.create", { name, folderToken }); const appToken = res.data?.app?.app_token; if (!appToken) throw new Error("Failed to create Bitable: no app_token returned"); const log = logger ?? { debug: () => {}, warn: () => {} }; let tableId; let cleanedRows = 0; let cleanedFields = 0; try { const tablesRes = await client.bitable.appTable.list({ path: { app_token: appToken } }); if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) { tableId = tablesRes.data.items[0].table_id ?? void 0; if (tableId) { const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log); cleanedRows = cleanup.cleanedRows; cleanedFields = cleanup.cleanedFields; } } } catch (err) { log.debug(`Cleanup failed (non-critical): ${String(err)}`); } return { app_token: appToken, table_id: tableId, name: res.data?.app?.name, url: res.data?.app?.url, cleaned_placeholder_rows: cleanedRows, cleaned_default_fields: cleanedFields, hint: tableId ? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.` : "Table created. Use feishu_bitable_get_meta to get table_id and field details." }; } async function createField(client, appToken, tableId, fieldName, fieldType, property) { const res = await client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: { field_name: fieldName, type: fieldType, ...property && { property } } }); ensureLarkSuccess(res, "bitable.appTableField.create", { appToken, tableId, fieldName, fieldType }); return { field_id: res.data?.field?.field_id, field_name: res.data?.field?.field_name, type: res.data?.field?.type, type_name: FIELD_TYPE_NAMES[res.data?.field?.type ?? 0] || `type_${res.data?.field?.type}` }; } async function updateRecord(client, appToken, tableId, recordId, fields) { const res = await client.bitable.appTableRecord.update({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, data: { fields } }); ensureLarkSuccess(res, "bitable.appTableRecord.update", { appToken, tableId, recordId }); return { record: res.data?.record }; } const GetMetaSchema = Type.Object({ url: Type.String({ description: "Bitable URL. Supports both formats: /base/XXX?table=YYY or /wiki/XXX?table=YYY" }) }); const ListFieldsSchema = Type.Object({ app_token: Type.String({ description: "Bitable app token (use feishu_bitable_get_meta to get from URL)" }), table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }) }); const ListRecordsSchema = Type.Object({ app_token: Type.String({ description: "Bitable app token (use feishu_bitable_get_meta to get from URL)" }), table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), page_size: optionalPositiveIntegerSchema({ description: "Number of records per page (1-500, default 100)", maximum: 500 }), page_token: Type.Optional(Type.String({ description: "Pagination token from previous response" })) }); const GetRecordSchema = Type.Object({ app_token: Type.String({ description: "Bitable app token (use feishu_bitable_get_meta to get from URL)" }), table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), record_id: Type.String({ description: "Record ID to retrieve" }) }); const CreateRecordSchema = Type.Object({ app_token: Type.String({ description: "Bitable app token (use feishu_bitable_get_meta to get from URL)" }), table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), fields: Type.Record(Type.String(), Type.Any(), { description: "Field values keyed by field name. Format by type: Text='string', Number=123, SingleSelect='Option', MultiSelect=['A','B'], DateTime=timestamp_ms, User=[{id:'ou_xxx'}], URL={text:'Display',link:'https://...'}" }) }); const CreateAppSchema = Type.Object({ name: Type.String({ description: "Name for the new Bitable application" }), folder_token: Type.Optional(Type.String({ description: "Optional folder token to place the Bitable in a specific folder" })) }); const CreateFieldSchema = Type.Object({ app_token: Type.String({ description: "Bitable app token (use feishu_bitable_get_meta to get from URL, or feishu_bitable_create_app to create new)" }), table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), field_name: Type.String({ description: "Name for the new field" }), field_type: Type.Number({ description: "Field type ID: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=SingleLink, 19=Lookup, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber", minimum: 1 }), property: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Field-specific properties (e.g., options for SingleSelect, format for Number)" })) }); const UpdateRecordSchema = Type.Object({ app_token: Type.String({ description: "Bitable app token (use feishu_bitable_get_meta to get from URL)" }), table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), record_id: Type.String({ description: "Record ID to update" }), fields: Type.Record(Type.String(), Type.Any(), { description: "Field values to update (same format as create_record)" }) }); function registerFeishuBitableTools(api) { if (!api.config) return; const accounts = listEnabledFeishuAccounts(api.config); if (accounts.length === 0) return; if (!resolveAnyEnabledFeishuToolsConfig(accounts).bitable) return; const getClient = (params, defaultAccountId) => { const account = resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }); if (!resolveToolsConfig(account.config.tools).bitable) throw new Error(`Feishu Bitable tools are disabled for account "${account.accountId}"`); return createFeishuClient(account); }; const registerBitableTool = (params) => { api.registerTool((ctx) => ({ name: params.name, label: params.label, description: params.description, parameters: params.parameters, async execute(_toolCallId, rawParams) { try { return json(await params.execute({ params: rawParams, defaultAccountId: ctx.agentAccountId })); } catch (err) { return json({ error: formatErrorMessage(err) }); } } }), { name: params.name }); }; registerBitableTool({ name: "feishu_bitable_get_meta", label: "Feishu Bitable Get Meta", description: "Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.", parameters: GetMetaSchema, async execute({ params, defaultAccountId }) { return getBitableMeta(getClient(params, defaultAccountId), params.url); } }); registerBitableTool({ name: "feishu_bitable_list_fields", label: "Feishu Bitable List Fields", description: "List all fields (columns) in a Bitable table with their types and properties", parameters: ListFieldsSchema, async execute({ params, defaultAccountId }) { return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id); } }); registerBitableTool({ name: "feishu_bitable_list_records", label: "Feishu Bitable List Records", description: "List records (rows) from a Bitable table with pagination support", parameters: ListRecordsSchema, async execute({ params, defaultAccountId }) { return listRecords(getClient(params, defaultAccountId), params.app_token, params.table_id, readBitableListRecordsPageSize(params), params.page_token); } }); registerBitableTool({ name: "feishu_bitable_get_record", label: "Feishu Bitable Get Record", description: "Get a single record by ID from a Bitable table", parameters: GetRecordSchema, async execute({ params, defaultAccountId }) { return getRecord(getClient(params, defaultAccountId), params.app_token, params.table_id, params.record_id); } }); registerBitableTool({ name: "feishu_bitable_create_record", label: "Feishu Bitable Create Record", description: "Create a new record (row) in a Bitable table", parameters: CreateRecordSchema, async execute({ params, defaultAccountId }) { return createRecord(getClient(params, defaultAccountId), params.app_token, params.table_id, params.fields); } }); registerBitableTool({ name: "feishu_bitable_update_record", label: "Feishu Bitable Update Record", description: "Update an existing record (row) in a Bitable table", parameters: UpdateRecordSchema, async execute({ params, defaultAccountId }) { return updateRecord(getClient(params, defaultAccountId), params.app_token, params.table_id, params.record_id, params.fields); } }); registerBitableTool({ name: "feishu_bitable_create_app", label: "Feishu Bitable Create App", description: "Create a new Bitable (multidimensional table) application", parameters: CreateAppSchema, async execute({ params, defaultAccountId }) { return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, { debug: (msg) => api.logger.debug?.(msg), warn: (msg) => api.logger.warn?.(msg) }); } }); registerBitableTool({ name: "feishu_bitable_create_field", label: "Feishu Bitable Create Field", description: "Create a new field (column) in a Bitable table", parameters: CreateFieldSchema, async execute({ params, defaultAccountId }) { return createField(getClient(params, defaultAccountId), params.app_token, params.table_id, params.field_name, params.field_type, params.property); } }); } //#endregion //#region extensions/feishu/api.ts const feishuSessionBindingAdapterChannels = ["feishu"]; //#endregion export { testing as __testing, testing as feishuThreadBindingTesting, testing, buildFeishuConversationId, buildFeishuModelOverrideParentCandidates, createClackPrompter, createFeishuThreadBindingManager, feishuPlugin, feishuSessionBindingAdapterChannels, feishuSetupAdapter, feishuSetupWizard, getFeishuThreadBindingManager, handleFeishuSubagentDeliveryTarget, handleFeishuSubagentEnded, handleFeishuSubagentSpawning, parseFeishuConversationId, parseFeishuDirectConversationId, parseFeishuTargetId, registerFeishuBitableTools, registerFeishuChatTools, registerFeishuDocTools, registerFeishuDriveTools, registerFeishuPermTools, registerFeishuWikiTools, runFeishuLogin, setFeishuNamedAccountEnabled };