Skill: mydreamhorse/feishu-whiteboard-v2 (version 1.0.0)

--- name: feishu-whiteboard description: Create and fill Feishu/Lark Whiteboard (画板) content from natural language requests by generating Mermaid or PlantUML code and calling Feishu Open API directly. Use when user asks to draw流程图/思维导图/架构图/时序图 in Feishu docs or wants AI-generated board diagrams. --- # Feishu Board (画板) Skill 将用户自然语言需求转成 Mermaid/PlantUML，然后通过飞书开放平台 API 创建并填充画板。 ## 核心流程（必须按顺序） 1. **理解意图**：判断图类型（流程图、时序图、类图、甘特图、思维导图等）。 2. **生成图语法**：优先生成 Mermaid；若用户明确要求 PlantUML 则用 PlantUML。 3. **调用脚本**：执行 `scripts/feishu-board.js` 完成“创建画板块 + 填充节点”。 4. **回报结果**：返回文档链接、画板 token、节点创建结果。 ## 环境变量 在执行前确认： - `FEISHU_APP_ID` - `FEISHU_APP_SECRET` 可选： - `FEISHU_BASE_URL`（默认 `https://open.feishu.cn/open-apis`） ## 参数约定 - `docId`: 文档 ID（非完整 URL） - `parentBlockId`: 插入位置父块（通常可先用 `docId` 作为根块） - `syntaxType`: `mermaid` 或 `plantuml` - `codeFile`: 临时代码文件路径 ## 推荐执行方式 先把图语法写入临时文件，再执行一体化命令： ```bash node skills/feishu-whiteboard/scripts/feishu-board.js run \ --doc-id <DOC_ID> \ --parent-block-id <PARENT_BLOCK_ID> \ --syntax-type mermaid \ --code-file /tmp/board.mmd ``` ## 子命令 - `run`: 一步完成（创建画板块 + 填充语法节点） - `create-whiteboard`: 只创建画板块并解析 whiteboard token - `fill-diagram`: 向已有画板 token 填充 Mermaid/PlantUML - `get-tenant-token`: 仅测试鉴权 ## 失败处理 - 若报权限不足，提示补齐 scope（至少）： - `board:whiteboard:node:create` - `board:whiteboard:node:read` - `docx:document` - 若无法从 block 解析出 token：先返回 `block_id`，提示用户检查文档块结构权限或改用已有 whiteboard token。 ## 参考资料 需要接口细节时读取： - `references/feishu-board-api.md`

path: references/feishu-board-api.md
content: # Feishu Board API Notes（给 Agent 的精简参考）

## 目标

从自然语言生成图表（Mermaid/PlantUML），并写入飞书画板（Whiteboard）。

## 已验证/参考到的关键接口

> 说明：官方文档页面为前端动态渲染，直接抓取文本常为空。以下以用户提供信息 + Feishu-MCP 源码行为为准。

### 1) 获取 tenant_access_token

- `POST /auth/v3/tenant_access_token/internal`
- Base URL: `https://open.feishu.cn/open-apis`
- Body:

```json
{
  "app_id": "<FEISHU_APP_ID>",
  "app_secret": "<FEISHU_APP_SECRET>"
}
```

### 2) 在 Docx 中创建 Whiteboard 块

- `POST /docx/v1/documents/{docId}/blocks/{parentBlockId}/children?document_revision_id=-1`
- 关键：`block_type = 43`（whiteboard）

示例：

```json
{
  "children": [
    { "block_type": 43 }
  ],
  "index": 0
}
```

创建后建议再请求块详情，解析 whiteboard token：

- `GET /docx/v1/documents/{docId}/blocks/{blockId}?document_revision_id=-1`

### 3) 向画板写入 Mermaid/PlantUML

根据 Feishu-MCP `feishuApiService.ts`：

- `POST /board/v1/whiteboards/{whiteboardToken}/nodes/plantuml`
- Body 字段：

```json
{
  "plant_uml_code": "<Mermaid or PlantUML source>",
  "style_type": 1,
  "syntax_type": 2
}
```

- `syntax_type`:
  - `1` = PlantUML
  - `2` = Mermaid

## 权限（最小集合）

- `board:whiteboard:node:create`
- `board:whiteboard:node:read`
- `docx:document`

## 与用户研究一致的点

- 两阶段流程：先在文档中创建 whiteboard 块，再填充图语法。
- 白板能力由 `board` 命名空间接口承载。
- `block_type: 43` 对应 whiteboard。

## 常见失败

1. **code != 0 / 403**：权限未开通或未发布审核。
2. **token 解析失败**：块详情字段结构可能因版本变化而不同；需打印 block detail 进行字段定位。
3. **语法渲染失败**：Mermaid/PlantUML 本身语法错误，先本地检查语法。


path: scripts/feishu-bitable.js
content: #!/usr/bin/env node
/**
 * Feishu Bitable API - Create spreadsheets with any size
 */

const fs = require('fs');

const BASE_URL = process.env.FEISHU_BASE_URL || 'https://open.feishu.cn/open-apis';
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;

function parseArgs(argv) {
  const [,, command, ...rest] = argv;
  const args = { _: command };
  for (let i = 0; i < rest.length; i++) {
    const cur = rest[i];
    if (cur.startsWith('--')) {
      const key = cur.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
      const next = rest[i + 1];
      if (!next || next.startsWith('--')) {
        args[key] = true;
      } else {
        args[key] = next;
        i++;
      }
    }
  }
  return args;
}

function requiredEnv() {
  if (!APP_ID || !APP_SECRET) {
    throw new Error('Missing FEISHU_APP_ID or FEISHU_APP_SECRET');
  }
}

async function feishuFetch(path, { method = 'GET', token, body, retryCount = 0, maxRetries = 5 } = {}) {
  const headers = { 'Content-Type': 'application/json' };
  if (token) headers.Authorization = `Bearer ${token}`;

  const res = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  let json;
  try {
    json = JSON.parse(text);
  } catch {
    if (res.status === 429 && retryCount < maxRetries) {
      const delay = Math.min(2000 * Math.pow(2, retryCount), 60000);
      console.error(`Rate limited, retrying in ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay));
      return feishuFetch(path, { method, token, body, retryCount: retryCount + 1, maxRetries });
    }
    throw new Error(`Non-JSON response: ${res.status} ${text.slice(0, 500)}`);
  }

  if (!res.ok || json.code !== 0) {
    if (json.code === 1254043 || json.msg?.includes('rate limit')) {
      if (retryCount < maxRetries) {
        const delay = Math.min(2000 * Math.pow(2, retryCount), 60000);
        console.error(`Rate limited (code ${json.code}), retrying in ${delay}ms...`);
        await new Promise(r => setTimeout(r, delay));
        return feishuFetch(path, { method, token, body, retryCount: retryCount + 1, maxRetries });
      }
    }
    throw new Error(`Feishu API error (${method} ${path}): HTTP ${res.status}, code=${json.code}, msg=${json.msg}`);
  }

  return json;
}

async function getTenantToken() {
  requiredEnv();
  const res = await feishuFetch('/auth/v3/tenant_access_token/internal', {
    method: 'POST',
    body: { app_id: APP_ID, app_secret: APP_SECRET },
  });
  return res.tenant_access_token;
}

// Parse markdown table
function parseMarkdownTable(md) {
  const lines = md.trim().split('\n');
  const rows = [];
  
  for (const line of lines) {
    const trimmed = line.trim();
    if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
      // Skip separator line (|---|)
      if (trimmed.match(/^\|[\s\-:|]+\|$/)) continue;
      
      const cells = trimmed.slice(1, -1).split('|').map(c => c.trim());
      rows.push(cells);
    }
  }
  
  return rows;
}

async function createBitable(token, spaceId, tableName, data) {
  if (data.length === 0) throw new Error('No data provided');
  
  const rowCount = data.length;
  const colCount = data[0].length;
  
  // Create bitable
  const createRes = await feishuFetch(
    `/bitable/v1/spreadsheets`,
    {
      method: 'POST',
      token,
      body: {
        folder_token: spaceId,
        title: tableName
      }
    }
  );
  
  const spreadsheetToken = createRes.data.spreadsheet_token;
  console.log(`Created spreadsheet: ${spreadsheetToken}`);
  
  // Get the default sheet
  const sheetsRes = await feishuFetch(
    `/bitable/v1/spreadsheets/${spreadsheetToken}/sheets`,
    { token }
  );
  
  const sheetId = sheetsRes.data.items[0].sheet_id;
  console.log(`Using sheet: ${sheetId}`);
  
  // Batch write data - Feishu API has limits, so we need to batch
  const BATCH_SIZE = 500; // Max cells per request
  const batchData = [];
  
  for (let r = 0; r < Math.min(rowCount, 100); r++) { // Limit rows for now
    for (let c = 0; c < colCount; c++) {
      batchData.push({
        row: r,
        col: c,
        value: data[r][c]
      });
      
      if (batchData.length >= BATCH_SIZE) {
        await writeBatch(token, spreadsheetToken, sheetId, batchData);
        batchData.length = 0;
      }
    }
  }
  
  if (batchData.length > 0) {
    await writeBatch(token, spreadsheetToken, sheetId, batchData);
  }
  
  return spreadsheetToken;
}

async function writeBatch(token, spreadsheetToken, sheetId, batchData) {
  // Build request for batch write
  const records = batchData.map(({ row, col, value }) => ({
    fields: { [col]: value }
  }));
  
  // Use batch append
  await feishuFetch(
    `/bitable/v1/spreadsheets/${spreadsheetToken}/sheets/${sheetId}/records`,
    {
      method: 'POST',
      token,
      body: { records }
    }
  );
}

async function main() {
  const args = parseArgs(process.argv);
  const cmd = args._;

  if (!cmd || ['-h', '--help', 'help'].includes(cmd)) {
    console.log('Usage: node feishu-bitable.js create --space-id <id> --title <name> --markdown-file <file>');
    return;
  }

  if (cmd === 'create') {
    if (!args.spaceId || !args.title || !args.markdownFile) {
      throw new Error('Missing required params: --space-id, --title, --markdown-file');
    }
    
    const md = fs.readFileSync(args.markdownFile, 'utf8');
    const data = parseMarkdownTable(md);
    
    console.log(`Creating table with ${data.length} rows, ${data[0]?.length || 0} columns`);
    
    const token = await getTenantToken();
    const spreadsheetToken = await createBitable(token, args.spaceId, args.title, data);
    
    console.log(`Done! Bitable URL: https://my.feishu.cn/bitable/${spreadsheetToken}`);
    console.log(`Spreadsheet [REDACTED_SECRET]`);
  }
}

main().catch(console.error);


path: scripts/feishu-board.js
content: #!/usr/bin/env node
/*
 * Feishu Board API helper
 * 中文说明：
 * - 支持 tenant_access_token 获取
 * - 在 docx 中创建 whiteboard block（block_type=43）
 * - 调用画板语法接口填充 Mermaid / PlantUML
 */

const fs = require('fs');

const BASE_URL = process.env.FEISHU_BASE_URL || 'https://open.feishu.cn/open-apis';
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;

function parseArgs(argv) {
  const [,, command, ...rest] = argv;
  const args = { _: command };
  for (let i = 0; i < rest.length; i++) {
    const cur = rest[i];
    if (cur.startsWith('--')) {
      const key = cur.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
      const next = rest[i + 1];
      if (!next || next.startsWith('--')) {
        args[key] = true;
      } else {
        args[key] = next;
        i++;
      }
    }
  }
  return args;
}

function requiredEnv() {
  if (!APP_ID || !APP_SECRET) {
    throw new Error('Missing FEISHU_APP_ID or FEISHU_APP_SECRET');
  }
}

async function feishuFetch(path, { method = 'GET', token, body } = {}) {
  const headers = { 'Content-Type': 'application/json' };
  if (token) headers.Authorization = `Bearer ${token}`;

  const res = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  let json;
  try {
    json = JSON.parse(text);
  } catch {
    throw new Error(`Non-JSON response: ${res.status} ${text.slice(0, 500)}`);
  }

  if (!res.ok || json.code !== 0) {
    throw new Error(`Feishu API error (${method} ${path}): HTTP ${res.status}, code=${json.code}, msg=${json.msg}, body=${text.slice(0, 800)}`);
  }

  return json;
}

async function getTenantToken() {
  requiredEnv();
  const res = await feishuFetch('/auth/v3/tenant_access_token/internal', {
    method: 'POST',
    body: { app_id: APP_ID, app_secret: APP_SECRET },
  });
  return res.tenant_access_token;
}

function deepFindWhiteboardToken(obj) {
  if (!obj || typeof obj !== 'object') return undefined;

  // board.token 是实际返回的字段名
  if (obj.board && typeof obj.board.token === 'string') return obj.board.token;
  if (typeof obj.whiteboard_token === 'string') return obj.whiteboard_token;
  if (obj.whiteboard && typeof obj.whiteboard.token === 'string') return obj.whiteboard.token;

  for (const key of Object.keys(obj)) {
    const v = obj[key];
    if (v && typeof v === 'object') {
      const found = deepFindWhiteboardToken(v);
      if (found) return found;
    }
  }
  return undefined;
}

async function createWhiteboardBlock({ token, docId, parentBlockId, index = 0 }) {
  const path = `/docx/v1/documents/${docId}/blocks/${parentBlockId}/children?document_revision_id=-1`;
  const payload = {
    children: [
      {
        block_type: 43,
        board: {
          align: 1,
        },
      },
    ],
    index: Number(index),
  };

  const createRes = await feishuFetch(path, {
    method: 'POST',
    token,
    body: payload,
  });

  const blockId = createRes?.data?.children?.[0]?.block_id || createRes?.data?.children?.[0];
  if (!blockId) {
    return { createRes, blockId: null, whiteboardToken: null };
  }

  const blockRes = await feishuFetch(
    `/docx/v1/documents/${docId}/blocks/${blockId}?document_revision_id=-1`,
    { method: 'GET', token }
  );

  const whiteboardToken = deepFindWhiteboardToken(blockRes?.data || blockRes);
  return { createRes, blockRes, blockId, whiteboardToken: whiteboardToken || null };
}

function normalizeSyntaxType(input) {
  if (!input) return 2;
  const s = String(input).toLowerCase();
  if (s === '1' || s === 'plantuml') return 1;
  if (s === '2' || s === 'mermaid') return 2;
  throw new Error(`Unsupported syntaxType: ${input}, expected mermaid|plantuml|1|2`);
}

async function fillDiagram({ token, whiteboardToken, code, syntaxType }) {
  const st = normalizeSyntaxType(syntaxType);
  const path = `/board/v1/whiteboards/${whiteboardToken}/nodes/plantuml`;
  const payload = {
    plant_uml_code: code,
    style_type: 1,
    syntax_type: st,
  };
  return feishuFetch(path, { method: 'POST', token, body: payload });
}

function readCode(args) {
  if (args.code) return args.code;
  if (args.codeFile) return fs.readFileSync(args.codeFile, 'utf8');
  throw new Error('Missing --code or --code-file');
}

function print(obj) {
  process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}

async function main() {
  const args = parseArgs(process.argv);
  const cmd = args._;

  if (!cmd || ['-h', '--help', 'help'].includes(cmd)) {
    print({
      usage: [
        'node feishu-board.js get-tenant-token',
        'node feishu-board.js create-whiteboard --doc-id <id> --parent-block-id <id> [--index 0]',
        'node feishu-board.js fill-diagram --whiteboard-token <token> --syntax-type mermaid|plantuml --code-file /tmp/board.mmd',
        'node feishu-board.js run --doc-id <id> --parent-block-id <id> --syntax-type mermaid|plantuml --code-file /tmp/board.mmd [--index 0]',
      ],
    });
    return;
  }

  if (cmd === 'get-tenant-token') {
    const token = await getTenantToken();
    print({ ok: true, tenantAccessToken: token });
    return;
  }

  const token = await getTenantToken();

  if (cmd === 'create-whiteboard') {
    if (!args.docId || !args.parentBlockId) throw new Error('Missing --doc-id or --parent-block-id');
    const out = await createWhiteboardBlock({
      token,
      docId: args.docId,
      parentBlockId: args.parentBlockId,
      index: args.index || 0,
    });
    print({ ok: true, ...out });
    return;
  }

  if (cmd === 'fill-diagram') {
    if (!args.whiteboardToken) throw new Error('Missing --whiteboard-token');
    const code = readCode(args);
    const res = await fillDiagram({ token, whiteboardToken: args.whiteboardToken, code, syntaxType: args.syntaxType || 'mermaid' });
    print({ ok: true, response: res });
    return;
  }

  if (cmd === 'run') {
    if (!args.docId || !args.parentBlockId) throw new Error('Missing --doc-id or --parent-block-id');
    const code = readCode(args);
    const created = await createWhiteboardBlock({
      token,
      docId: args.docId,
      parentBlockId: args.parentBlockId,
      index: args.index || 0,
    });

    if (!created.whiteboardToken) {
      print({
        ok: false,
        message: 'Whiteboard block created but token not found from block detail. Use returned blockId to inspect docx block schema or provide existing whiteboard token and run fill-diagram.',
        blockId: created.blockId || null,
        createRes: created.createRes,
        blockRes: created.blockRes || null,
      });
      process.exitCode = 2;
      return;
    }

    const fillRes = await fillDiagram({
      token,
      whiteboardToken: created.whiteboardToken,
      code,
      syntaxType: args.syntaxType || 'mermaid',
    });

    print({
      ok: true,
      whiteboardToken: created.whiteboardToken,
      blockId: created.blockId,
      createWhiteboard: created.createRes,
      fillDiagram: fillRes,
    });
    return;
  }

  throw new Error(`Unknown command: ${cmd}`);
}

main().catch((err) => {
  process.stderr.write(`[feishu-board] ${err.message}\n`);
  process.exit(1);
});


path: scripts/feishu-markdown-to-docx.js
content: #!/usr/bin/env node
/**
 * feishu-markdown-to-docx - 将 Markdown 转换为飞书文档，支持表格
 */

const fs = require('fs');
const { 
  parseMarkdown, 
  generateBlockId,
  BlockType 
} = require('feishu-markdown');

const BASE_URL = process.env.FEISHU_BASE_URL || 'https://open.feishu.cn/open-apis';
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;

function parseArgs(argv) {
  const [,, command, ...rest] = argv;
  const args = { _: command };
  for (let i = 0; i < rest.length; i++) {
    const cur = rest[i];
    if (cur.startsWith('--')) {
      const key = cur.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
      const next = rest[i + 1];
      if (!next || next.startsWith('--')) {
        args[key] = true;
      } else {
        args[key] = next;
        i++;
      }
    }
  }
  return args;
}

function requiredEnv() {
  if (!APP_ID || !APP_SECRET) {
    throw new Error('Missing FEISHU_APP_ID or FEISHU_APP_SECRET');
  }
}

async function feishuFetch(path, { method = 'GET', token, body, retryCount = 0, maxRetries = 5 } = {}) {
  const headers = { 'Content-Type': 'application/json' };
  if (token) headers.Authorization = `Bearer ${token}`;

  const res = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  let json;
  try {
    json = JSON.parse(text);
  } catch {
    // Handle rate limiting with retry
    if (res.status === 429 && retryCount < maxRetries) {
      const delay = Math.min(2000 * Math.pow(2, retryCount), 60000); // Exponential backoff
      console.error(`Rate limited, retrying in ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay));
      return feishuFetch(path, { method, token, body, retryCount: retryCount + 1, maxRetries });
    }
    throw new Error(`Non-JSON response: ${res.status} ${text.slice(0, 500)}`);
  }

  if (!res.ok || json.code !== 0) {
    // Handle specific rate limit error code
    if (json.code === 1254043 || json.msg?.includes('rate limit') || json.msg?.includes('Rate limit')) {
      if (retryCount < maxRetries) {
        const delay = Math.min(2000 * Math.pow(2, retryCount), 60000);
        console.error(`Rate limited (code ${json.code}), retrying in ${delay}ms...`);
        await new Promise(r => setTimeout(r, delay));
        return feishuFetch(path, { method, token, body, retryCount: retryCount + 1, maxRetries });
      }
    }
    throw new Error(`Feishu API error (${method} ${path}): HTTP ${res.status}, code=${json.code}, msg=${json.msg}`);
  }

  return json;
}

async function getTenantToken() {
  requiredEnv();
  const res = await feishuFetch('/auth/v3/tenant_access_token/internal', {
    method: 'POST',
    body: { app_id: APP_ID, app_secret: APP_SECRET },
  });
  return res.tenant_access_token;
}

function mapLanguage(lang) {
  const map = {
    javascript: 30, js: 30,
    python: 49, py: 49,
    java: 29,
    go: 22,
    rust: 53,
    typescript: 63, ts: 63,
    sql: 56,
    bash: 7, shell: 60,
    json: 28,
    html: 24,
    css: 12,
    yaml: 67, yml: 67,
    xml: 66,
    markdown: 39, md: 39,
    plaintext: 1
  };
  return map[lang.toLowerCase()] || 1;
}

/**
 * 将 Markdown AST 转换为飞书 blocks
 */
function transformMarkdownToBlocks(ast) {
  const blocks = [];
  
  function processNode(node, parentList = null) {
    if (!node) return;
    
    if (node.type === 'root' || node.type === 'document') {
      for (const child of node.children || []) {
        processNode(child, blocks);
      }
    } else if (node.type === 'heading') {
      const level = Math.min(node.depth, 9);
      const content = (node.children || []).map(c => c.value || '').join('');
      const headingKey = 'heading' + level;
      blocks.push({
        block_type: level + 2,
        [headingKey]: {
          elements: [{ text_run: { content } }]
        }
      });
    } else if (node.type === 'paragraph') {
      const content = (node.children || []).map(c => c.value || '').join('');
      if (content.trim()) {
        blocks.push({
          block_type: 2,
          text: { elements: [{ text_run: { content } }] }
        });
      }
    } else if (node.type === 'list') {
      const isOrdered = node.ordered || false;
      for (const item of node.children || []) {
        const textContent = (item.children || []).map(c => {
          if (c.children) return c.children.map(cc => cc.value || '').join('');
          return c.value || '';
        }).join('');
        
        blocks.push({
          block_type: isOrdered ? 13 : 12,
          [isOrdered ? 'ordered' : 'bullet']: {
            elements: [{ text_run: { content: textContent } }]
          }
        });
      }
    } else if (node.type === 'code') {
      const content = node.value || '';
      const lang = node.lang || '';
      blocks.push({
        block_type: 14,
        code: {
          elements: [{ text_run: { content } }],
          style: { language: mapLanguage(lang), wrap: false }
        }
      });
    } else if (node.type === 'table') {
      const rows = node.children || [];
      const rowCount = rows.length;
      const colCount = rows[0] && rows[0].children ? rows[0].children.length : 0;
      
      if (rowCount > 0 && colCount > 0) {
        const MAX_CELLS = 28; // Feishu API limit per table
        
        // Collect all cell contents
        const allCellContents = [];
        for (let r = 0; r < rowCount; r++) {
          const row = rows[r];
          const cells = row.children || [];
          const rowContents = [];
          for (let c = 0; c < colCount; c++) {
            const content = (cells[c]?.children || []).map(cc => cc.value || '').join('');
            rowContents.push(content);
          }
          allCellContents.push(rowContents);
        }
        
        // If small enough, create as single table
        if (rowCount * colCount <= MAX_CELLS) {
          const tableId = generateBlockId();
          const cellIds = allCellContents.flatMap(() => 
            Array(colCount).fill(null).map(() => generateBlockId())
          );
          
          blocks.push({
            _isTable: true,
            tableId,
            cellIds,
            cellContents: allCellContents,
            rowCount,
            colCount
          });
        } else {
          // Split into multiple tables with header repeat
          const maxRowsPerTable = Math.floor(MAX_CELLS / colCount);
          
          for (let startRow = 0; startRow < rowCount; startRow += maxRowsPerTable) {
            const endRow = Math.min(startRow + maxRowsPerTable, rowCount);
            const subRowCount = endRow - startRow;
            const tableId = generateBlockId();
            
            const subCellContents = allCellContents.slice(startRow, endRow);
            const cellIds = subCellContents.flatMap(() => 
              Array(colCount).fill(null).map(() => generateBlockId())
            );
            
            blocks.push({
              _isTable: true,
              tableId,
              cellIds,
              cellContents: subCellContents,
              rowCount: subRowCount,
              colCount,
              _isPartial: true,
              _isLast: endRow >= rowCount
            });
            
            // No divider - tables will be adjacent
          }
        }
      }
    } else if (node.type === 'thematicBreak') {
      blocks.push({ block_type: 22, divider: {} });
    }
  }
  
  processNode(ast);
  return blocks;
}

// Helper: add cells to existing table in batches
async function addTableCells(token, docId, tableBlockId, newCellIds) {
  const BATCH_SIZE = 10; // Smaller batch size to avoid API limit
  const createdCellIds = [];
  
  for (let i = 0; i < newCellIds.length; i += BATCH_SIZE) {
    const batch = newCellIds.slice(i, i + BATCH_SIZE);
    const res = await feishuFetch(
      `/docx/v1/documents/${docId}/blocks/${tableBlockId}/children?document_revision_id=-1`,
      {
        method: 'POST',
        token,
        body: { children: batch.map(id => ({ block_id: id, block_type: 32 })) }
      }
    );
    if (res.data?.children) {
      createdCellIds.push(...res.data.children.map(c => c.block_id));
    }
  }
  
  return createdCellIds;
}

async function appendBlockToDoc(token, docId, parentBlockId, block) {
  // Handle table specially
  if (block._isTable) {
    const { tableId, cellIds, cellContents, rowCount, colCount } = block;
    
    // Create table block
    const tableRes = await feishuFetch(
      `/docx/v1/documents/${docId}/blocks/${parentBlockId}/children?document_revision_id=-1`,
      {
        method: 'POST',
        token,
        body: {
          children: [{
            block_id: tableId,
            block_type: 31,
            table: {
              property: { row_size: rowCount, column_size: colCount }
            },
            children: cellIds
          }]
        }
      }
    );
    
    // Get the actual created table block ID and cell IDs from response
    const createdTable = tableRes.data?.children?.[0];
    const createdTableBlockId = createdTable?.block_id;
    const createdCellIds = createdTable?.children || [];
    
    if (!createdTableBlockId) {
      throw new Error('Failed to create table: ' + JSON.stringify(tableRes));
    }
    
    // Add content to each cell using the REAL cell IDs from Feishu response
    for (let i = 0; i < createdCellIds.length; i++) {
      const realCellId = createdCellIds[i];
      const row = Math.floor(i / colCount);
      const col = i % colCount;
      const content = cellContents[row]?.[col] || '';
      
      await feishuFetch(
        `/docx/v1/documents/${docId}/blocks/${realCellId}/children?document_revision_id=-1`,
        {
          method: 'POST',
          token,
          body: {
            children: [{
              block_type: 2,
              text: { elements: [{ text_run: { content } }] }
            }]
          }
        }
      );
    }
    
    return { type: 'table', status: 'created' };
  }
  
  // Regular block
  const res = await feishuFetch(
    `/docx/v1/documents/${docId}/blocks/${parentBlockId}/children?document_revision_id=-1`,
    {
      method: 'POST',
      token,
      body: { children: [block] }
    }
  );
  
  return { type: 'block', status: res.data };
}

function print(obj) {
  process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}

async function main() {
  const args = parseArgs(process.argv);
  const cmd = args._;

  if (!cmd || ['-h', '--help', 'help'].includes(cmd)) {
    print({
      usage: [
        'node feishu-markdown-to-docx.js run --doc-id <id> --parent-block-id <id> --markdown-file <file>',
        'Example:',
        '  node feishu-markdown-to-docx.js run --doc-id xxx --parent-block-id xxx --markdown-file /tmp/test.md'
      ]
    });
    return;
  }

  if (cmd === 'run') {
    if (!args.docId || !args.parentBlockId || !args.markdownFile) {
      throw new Error('Missing required params: --doc-id, --parent-block-id, --markdown-file');
    }
    
    const md = fs.readFileSync(args.markdownFile, 'utf8');
    const ast = parseMarkdown(md);
    const blocks = transformMarkdownToBlocks(ast);
    
    const token = await getTenantToken();
    const results = [];
    
    for (let i = 0; i < blocks.length; i++) {
      const block = blocks[i];
      try {
        const result = await appendBlockToDoc(token, args.docId, args.parentBlockId, block);
        results.push(result);
        // Add delay between blocks - longer for tables
        const delay = block._isTable ? 1500 : 500;
        await new Promise(r => setTimeout(r, delay));
      } catch (e) {
        results.push({ type: 'error', message: e.message });
        // On error, wait much longer
        await new Promise(r => setTimeout(r, 5000));
      }
    }
    
    print({ ok: true, blocksCreated: blocks.length, results });
    return;
  }

  throw new Error(`Unknown command: ${cmd}`);
}

main().catch((err) => {
  process.stderr.write(`[feishu-markdown-to-docx] ${err.message}\n`);
  process.exit(1);
});


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)