commit 52f14ad26606fbc764820948747c242254721469 Author: xiteng Date: Sat Jun 13 20:36:44 2026 +0800 feishu-docs: Feishu Docx API skill for programmatic document creation and editing diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..35027c6 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,186 @@ +--- +name: feishu-docs +description: Create and edit Feishu/Lark documents programmatically via the Docx Open API — create docs, add content blocks, read raw text, and manage permissions. Use when the user asks to create a Feishu doc from scratch or add structured content. +version: 1.0.0 +author: agent +metadata: + hermes: + tags: [feishu, lark, documents, docx, api, content] + related_skills: [homelab-services] +--- + +# Feishu Docs — Document Creation & Editing + +## When to Use + +- User asks you to create a Feishu document (docx) with structured content +- User wants a report, summary, or reference saved as a Feishu doc +- User says "帮我创建一份飞书文档" or similar +- Supplement to lark-mcp MCP tools (which cover search, import, bitable, IM but not block-level editing) + +## Trigger Conditions + +- Keywords: 飞书文档, 创建文档, 写个文档, Feishu doc, Lark docx +- NOT: 飞书表格 (use lark-mcp bitable tools), 发送消息 (use `send_message` or lark-mcp IM tools) + +## Architecture + +Two complementary systems: + +| System | Strengths | Gaps | +|--------|-----------|------| +| **lark-mcp** (MCP server) | Bitable CRUD, IM/groups, document search, markdown→doc import | No block-level editing, no doc creation | +| **Feishu Docx API** (this skill) | Create docs, add/edit/delete blocks, read raw content | Requires manual API calls via Python | + +Use lark-mcp for everything it covers. Fall back to direct API for document creation and block editing. + +## Prerequisites + +The Feishu app must have these permissions in the developer console: +- `docx:document` — create and manage documents +- `drive:drive` — access cloud documents (for import API) + +Without these, the API returns 404. User must add them at https://open.feishu.cn/app. + +Credentials are in `~/.hermes/.env`: +``` +FEISHU_APP_ID=cli_xxx +FEISHU_APP_SECRET=xxx +``` + +## Workflow: Create a Document from Scratch + +### Step 1: Get tenant access token + +```python +import requests +r = requests.post( + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": aid, "app_secret": sec}, timeout=10 +) +token = r.json()["tenant_access_token"] +headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} +``` + +### Step 2: Create empty document + +```python +r = requests.post( + "https://open.feishu.cn/open-apis/docx/v1/documents", + headers=headers, + json={"title": "文档标题"}, + timeout=10 +) +doc_id = r.json()["data"]["document"]["document_id"] +# URL: https://xiteng.feishu.cn/docx/{doc_id} +``` + +### Step 3: Add content blocks + +The document root is also the root block. Add children to it: + +```python +blocks = [ + {"block_type": 3, "heading1": {"elements": [ + {"text_run": {"content": "大标题"}} + ]}}, + {"block_type": 2, "text": {"elements": [ + {"text_run": {"content": "正文段落"}} + ]}}, + {"block_type": 12, "bullet": {"elements": [ + {"text_run": {"content": "列表项"}} + ]}}, +] + +# Batch up to 50 blocks per request +r = requests.post( + f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_id}/children", + headers=headers, + json={"children": blocks}, + timeout=15 +) +``` + +### Block type reference + +| block_type | Name | Structure | +|------------|------|-----------| +| 2 | Text paragraph | `{"text": {"elements": [{"text_run": {"content": "..."}}]}}` | +| 3 | Heading 1 | `{"heading1": {"elements": ...}}` | +| 4 | Heading 2 | `{"heading2": {"elements": ...}}` | +| 5 | Heading 3 | `{"heading3": {"elements": ...}}` | +| 6 | Heading 4 | `{"heading4": {"elements": ...}}` | +| 12 | Bullet list | `{"bullet": {"elements": ...}}` | +| 13 | Ordered list | `{"ordered": {"elements": ...}}` | +| 14 | Code block | `{"code": {"elements": ..., "style": {"language": 1}}}` | +| 17 | Callout/hint | `{"callout": {"elements": ...}}` | +| 22 | Divider | `{"divider": {}}` | +| 27 | Table | Complex — see API docs | + +### Step 4: Read back and verify + +```python +r = requests.get( + f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/raw_content", + headers={"Authorization": f"Bearer {token}"}, + timeout=10 +) +content = r.json()["data"]["content"] +``` + +## Common Pitfalls + +### 1. 404 on import API — missing permissions +The `drive/v1/files/import` API requires `drive:drive` permission. Without it, returns 404. +**Fix**: User must add the permission in Feishu Developer Console → App → Permissions. + +### 2. ENV var redaction in scripts +When writing Python scripts that read `~/.hermes/.env`, do NOT write lines like: +```python +# ❌ Redaction will eat the string +elif line.startswith("FEISHU_APP_SECRET=*** ... +``` +Instead construct the key name dynamically: +```python +# ✅ Safe from redaction +if k == "FEISHU_APP_SECRET": ... +# or +prefix = "FEISHU_APP" +if k == prefix + "_SECRET": ... +``` + +### 3. Block batch limit +The children API accepts at most 50 blocks per request. Split large documents into batches. + +### 4. Document ID = Root block ID +The `document_id` returned from create is also the root block ID. Use it for both: +- `GET /docx/v1/documents/{doc_id}` — get document metadata +- `POST /docx/v1/documents/{doc_id}/blocks/{doc_id}/children` — add blocks + +## Integration with lark-mcp + +When lark-mcp is connected (see `references/lark-mcp-setup.md`), use it for: +- `docx_builtin_search` — search existing documents +- `docx_v1_document_rawContent` — read document plain text +- `docx_builtin_import` — quick markdown→doc conversion (creates new doc) +- `bitable_*` — multi-dimensional table operations +- `im_*` — messages and groups + +Use direct API (this skill) when you need: +- Create a blank document with a specific title +- Add/edit/delete individual blocks +- Modify existing document structure + +## Verification Checklist + +- [ ] `FEISHU_APP_ID` and `FEISHU_APP_SECRET` are set in `.env` +- [ ] App has `docx:document` permission +- [ ] Document created: `GET /docx/v1/documents/{id}` returns 200 +- [ ] Content added: `GET /docx/v1/documents/{id}/blocks` returns blocks +- [ ] User can open the URL in Feishu + +## Related Files + +- `scripts/create_doc.py` — reusable script: create doc + add content from markdown +- `references/lark-mcp-setup.md` — MCP server installation and credential management +- `references/docx-block-types.md` — full block type reference with examples diff --git a/references/lark-mcp-setup.md b/references/lark-mcp-setup.md new file mode 100644 index 0000000..a01b09c --- /dev/null +++ b/references/lark-mcp-setup.md @@ -0,0 +1,82 @@ +# lark-mcp MCP Server Setup + +## Overview + +The [lark-mcp](https://github.com/whatevertogo/FeiShuSkill) package wraps the official Feishu/Lark MCP server +(`@larksuiteoapi/lark-mcp`), exposing 19 tools for bitable, IM, document search, and wiki operations. + +## Installation + +```bash +hermes mcp add lark-mcp \ + --command npx \ + --args -y --args @larksuiteoapi/lark-mcp --args mcp \ + --args -a --args \ + --args -s --args +``` + +## Common Pitfall: `hermes mcp add` produces malformed config + +**Symptom**: After adding via CLI, `hermes mcp test lark-mcp` fails with "Connection closed". + +**Root cause**: `hermes mcp add` may include the literal string `--args` as a positional argument +in the config's `args` list, producing: +```yaml +args: + - -y + - --args # ← should NOT be here + - '@larksuiteoapi/lark-mcp' + - --args # ← should NOT be here + - mcp +``` + +**Fix**: Manually edit `~/.hermes/config.yaml` under `mcp_servers.lark-mcp`: +```yaml +lark-mcp: + command: npx + args: + - -y + - '@larksuiteoapi/lark-mcp' + - mcp + - -a + - cli_xxxxxxxxxx + - -s + - your_secret_here + enabled: true +``` + +The `--args` tokens must NOT appear in the final config — only the actual argument values. + +## Enabling after fix + +After correcting the config, the MCP tools load on next session start. To load immediately: +- Hermes CLI: `/reload-mcp` then `/reset` +- Or restart Hermes + +## Available Tools (19 total) + +| Category | Tools | +|----------|-------| +| Bitable | `bitable_v1_app_create`, `bitable_v1_appTable_create/list`, `bitable_v1_appTableField_list`, `bitable_v1_appTableRecord_create/search/update` | +| IM | `im_v1_message_create/list`, `im_v1_chat_create/list`, `im_v1_chatMembers_get` | +| Document | `docx_builtin_search`, `docx_v1_document_rawContent`, `docx_builtin_import` | +| Wiki | `wiki_v1_node_search`, `wiki_v2_space_getNode` | +| Drive | `drive_v1_permissionMember_create` | +| Contact | `contact_v3_user_batchGetId` | + +## Tool Naming Convention + +When calling from Hermes: `mcp__lark-mcp__tool_name` (double underscore between server and tool). + +## Required Permissions + +The Feishu app needs these permissions in the developer console: + +| Permission | Required for | +|------------|-------------| +| `im:message` | Send/receive messages | +| `im:chat` | Create groups, list members | +| `bitable:app` | Multi-dimensional table operations | +| `docx:document` | Read document content | +| `drive:drive` | Search documents, import files | +| `wiki:wiki` | Knowledge base operations | diff --git a/scripts/create_doc.py b/scripts/create_doc.py new file mode 100644 index 0000000..bd5d460 --- /dev/null +++ b/scripts/create_doc.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Create a Feishu document and populate it with content blocks. + +Usage: python3 scripts/create_doc.py <markdown_file> + +Reads Feishu credentials from ~/.hermes/.env (FEISHU_APP_ID, FEISHU_APP_SECRET). +Creates a docx document with the given title, then converts basic markdown +to Feishu doc blocks and adds them. + +Currently supports: # H1, ## H2, ### H3, - bullet, blank line separator, +plain text paragraphs. For richer content, extend build_blocks(). +""" + +import json, os, re, requests, sys + + +def get_feishu_token(): + """Get tenant access token from Feishu Open API.""" + env_path = os.path.expanduser("~/.hermes/.env") + creds = {} + with open(env_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + creds[k.strip()] = v.strip().strip('"').strip("'") + + aid = creds.get("FEISHU_APP_ID") + sec = creds.get("FEISHU_APP_SECRET") + if not aid or not sec: + sys.exit("FEISHU_APP_ID or FEISHU_APP_SECRET not found in .env") + + r = requests.post( + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": aid, "app_secret": sec}, + timeout=10, + ) + return r.json()["tenant_access_token"], aid, sec + + +def text_element(content): + return [{"text_run": {"content": content}}] + + +def build_blocks(markdown_text): + """Convert basic markdown to Feishu doc blocks.""" + blocks = [] + lines = markdown_text.strip().split("\n") + i = 0 + while i < len(lines): + line = lines[i] + + # Blank line → divider or skip + if not line.strip(): + # Two blank lines → divider + if i + 1 < len(lines) and not lines[i + 1].strip(): + blocks.append({"block_type": 22, "divider": {}}) + i += 2 + continue + i += 1 + continue + + # Heading 3 + if line.startswith("### "): + blocks.append({"block_type": 5, "heading3": {"elements": text_element(line[4:])}}) + # Heading 2 + elif line.startswith("## "): + blocks.append({"block_type": 4, "heading2": {"elements": text_element(line[3:])}}) + # Heading 1 + elif line.startswith("# "): + blocks.append({"block_type": 3, "heading1": {"elements": text_element(line[2:])}}) + # Bullet + elif line.startswith("- ") or line.startswith("* "): + blocks.append({"block_type": 12, "bullet": {"elements": text_element(line[2:])}}) + # Plain text + else: + blocks.append({"block_type": 2, "text": {"elements": text_element(line)}}) + i += 1 + + return blocks + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 create_doc.py <title> [markdown_file]") + print(" If no markdown_file, reads from stdin.") + sys.exit(1) + + title = sys.argv[1] + if len(sys.argv) >= 3: + with open(sys.argv[2], encoding="utf-8") as f: + markdown = f.read() + else: + markdown = sys.stdin.read() + + token, _, _ = get_feishu_token() + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + # Step 1: Create document + print(f"Creating doc: {title}") + r = requests.post( + "https://open.feishu.cn/open-apis/docx/v1/documents", + headers=headers, + json={"title": title}, + timeout=10, + ) + result = r.json() + if result.get("code") != 0: + sys.exit(f"Create failed: {result.get('msg')}") + + doc_id = result["data"]["document"]["document_id"] + print(f" doc_id: {doc_id}") + + # Step 2: Build and add blocks + blocks = build_blocks(markdown) + if not blocks: + print(" No blocks to add (empty markdown)") + else: + batch_size = 50 + for i in range(0, len(blocks), batch_size): + batch = blocks[i:i + batch_size] + r = requests.post( + f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_id}/children", + headers=headers, + json={"children": batch}, + timeout=15, + ) + result = r.json() + if result.get("code") != 0: + sys.exit(f"Block add failed (batch {i//batch_size}): {result.get('msg')}") + print(f" Batch {i//batch_size + 1}: {len(batch)} blocks OK") + + url = f"https://xiteng.feishu.cn/docx/{doc_id}" + print(f"\n✅ {url}") + print(doc_id) + + +if __name__ == "__main__": + main()