mirror of
https://github.com/titanwings/colleague-skill.git
synced 2026-04-04 22:59:06 +08:00
功能: - 通过飞书/钉钉自动采集同事的消息、文档、多维表格 - 支持 PDF、邮件、截图等手动上传 - 分析生成 Work Skill(工作能力)和 Persona(人物性格)两部分 - 支持对话纠正和版本管理 - 兼容 OpenClaw 和 Claude Code 双平台 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
10 KiB
Python
311 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
飞书 MCP 客户端封装(cso1z/Feishu-MCP 方案)
|
||
|
||
通过 Feishu MCP Server 读取文档、wiki、消息记录。
|
||
适合:公司已授权的文档、有 App token 权限的内容。
|
||
|
||
前置要求:
|
||
1. 安装 Feishu MCP:npm install -g feishu-mcp
|
||
2. 配置 App ID 和 App Secret(飞书开放平台创建企业自建应用)
|
||
3. 给应用开通必要权限(见下方 REQUIRED_PERMISSIONS)
|
||
|
||
权限列表(飞书开放平台 → 权限管理 → 开通):
|
||
- docs:doc:readonly 读取文档
|
||
- wiki:wiki:readonly 读取知识库
|
||
- im:message:readonly 读取消息
|
||
- bitable:app:readonly 读取多维表格
|
||
- sheets:spreadsheet:readonly 读取表格
|
||
|
||
用法:
|
||
# 配置 token(一次性)
|
||
python3 feishu_mcp_client.py --setup
|
||
|
||
# 读取文档
|
||
python3 feishu_mcp_client.py --url "https://xxx.feishu.cn/wiki/xxx" --output out.txt
|
||
|
||
# 读取消息记录
|
||
python3 feishu_mcp_client.py --chat-id "oc_xxx" --target "张三" --output out.txt
|
||
|
||
# 列出某空间下的所有文档
|
||
python3 feishu_mcp_client.py --list-wiki --space-id "xxx"
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
|
||
CONFIG_PATH = Path.home() / ".colleague-skill" / "feishu_config.json"
|
||
|
||
|
||
# ─── 配置管理 ────────────────────────────────────────────────────────────────
|
||
|
||
def load_config() -> dict:
|
||
if CONFIG_PATH.exists():
|
||
return json.loads(CONFIG_PATH.read_text())
|
||
return {}
|
||
|
||
|
||
def save_config(config: dict) -> None:
|
||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
||
print(f"配置已保存到 {CONFIG_PATH}")
|
||
|
||
|
||
def setup_config() -> None:
|
||
print("=== 飞书 MCP 配置 ===")
|
||
print("请前往飞书开放平台(open.feishu.cn)创建企业自建应用,获取以下信息:\n")
|
||
|
||
app_id = input("App ID (cli_xxx): ").strip()
|
||
app_secret = input("App Secret: ").strip()
|
||
|
||
print("\n配置方式选择:")
|
||
print(" [1] App Token(应用权限,需要在飞书后台开通对应权限)")
|
||
print(" [2] User Token(个人权限,能访问你本人有权限的所有内容,需要定期刷新)")
|
||
mode = input("选择 [1/2],默认 1:").strip() or "1"
|
||
|
||
config = {
|
||
"app_id": app_id,
|
||
"app_secret": app_secret,
|
||
"mode": "app" if mode == "1" else "user",
|
||
}
|
||
|
||
if mode == "2":
|
||
print("\n获取 User Token:飞书开放平台 → OAuth 2.0 → 获取 user_access_token")
|
||
user_token = input("User Access Token (u-xxx):").strip()
|
||
config["user_token"] = user_token
|
||
print("注意:User Token 有效期约 2 小时,过期后需要重新配置")
|
||
|
||
save_config(config)
|
||
print("\n✅ 配置完成!")
|
||
|
||
|
||
# ─── MCP 调用封装 ─────────────────────────────────────────────────────────────
|
||
|
||
def call_mcp(tool: str, params: dict, config: dict) -> dict:
|
||
"""
|
||
通过 npx 调用 feishu-mcp 工具。
|
||
feishu-mcp 支持 stdio 模式,直接 JSON 通信。
|
||
"""
|
||
env = os.environ.copy()
|
||
env["FEISHU_APP_ID"] = config.get("app_id", "")
|
||
env["FEISHU_APP_SECRET"] = config.get("app_secret", "")
|
||
|
||
if config.get("mode") == "user" and config.get("user_token"):
|
||
env["FEISHU_USER_ACCESS_TOKEN"] = config["user_token"]
|
||
|
||
payload = json.dumps({
|
||
"jsonrpc": "2.0",
|
||
"method": "tools/call",
|
||
"params": {
|
||
"name": tool,
|
||
"arguments": params,
|
||
},
|
||
"id": 1,
|
||
})
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
["npx", "-y", "feishu-mcp", "--stdio"],
|
||
input=payload,
|
||
capture_output=True,
|
||
text=True,
|
||
env=env,
|
||
timeout=30,
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"MCP 调用失败:{result.stderr}")
|
||
return json.loads(result.stdout)
|
||
except FileNotFoundError:
|
||
print("错误:未找到 npx,请先安装 Node.js", file=sys.stderr)
|
||
print("安装 Feishu MCP:npm install -g feishu-mcp", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
def extract_doc_token(url: str) -> tuple[str, str]:
|
||
"""从飞书 URL 中提取文档 token 和类型"""
|
||
import re
|
||
patterns = [
|
||
(r"/wiki/([A-Za-z0-9]+)", "wiki"),
|
||
(r"/docx/([A-Za-z0-9]+)", "docx"),
|
||
(r"/docs/([A-Za-z0-9]+)", "doc"),
|
||
(r"/sheets/([A-Za-z0-9]+)", "sheet"),
|
||
(r"/base/([A-Za-z0-9]+)", "base"),
|
||
]
|
||
for pattern, doc_type in patterns:
|
||
m = re.search(pattern, url)
|
||
if m:
|
||
return m.group(1), doc_type
|
||
raise ValueError(f"无法从 URL 解析文档 token:{url}")
|
||
|
||
|
||
# ─── 功能函数 ─────────────────────────────────────────────────────────────────
|
||
|
||
def fetch_doc_via_mcp(url: str, config: dict) -> str:
|
||
"""通过 MCP 读取飞书文档或 Wiki"""
|
||
token, doc_type = extract_doc_token(url)
|
||
|
||
if doc_type == "wiki":
|
||
result = call_mcp("get_wiki_node", {"token": token}, config)
|
||
elif doc_type in ("docx", "doc"):
|
||
result = call_mcp("get_doc_content", {"doc_token": token}, config)
|
||
elif doc_type == "sheet":
|
||
result = call_mcp("get_spreadsheet_content", {"spreadsheet_token": token}, config)
|
||
else:
|
||
raise ValueError(f"不支持的文档类型:{doc_type}")
|
||
|
||
# 提取 MCP 返回的内容
|
||
if "result" in result:
|
||
content = result["result"]
|
||
if isinstance(content, list):
|
||
# MCP tool result 格式
|
||
for item in content:
|
||
if isinstance(item, dict) and item.get("type") == "text":
|
||
return item.get("text", "")
|
||
elif isinstance(content, str):
|
||
return content
|
||
elif "error" in result:
|
||
raise RuntimeError(f"MCP 返回错误:{result['error']}")
|
||
|
||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||
|
||
|
||
def fetch_messages_via_mcp(
|
||
chat_id: str,
|
||
target_name: str,
|
||
limit: int,
|
||
config: dict,
|
||
) -> str:
|
||
"""通过 MCP 读取群聊消息记录"""
|
||
result = call_mcp(
|
||
"get_chat_messages",
|
||
{
|
||
"chat_id": chat_id,
|
||
"page_size": min(limit, 50), # 飞书 API 单次最多 50 条
|
||
},
|
||
config,
|
||
)
|
||
|
||
messages = []
|
||
raw = result.get("result", [])
|
||
if isinstance(raw, list):
|
||
messages = raw
|
||
elif isinstance(raw, str):
|
||
try:
|
||
messages = json.loads(raw)
|
||
except Exception:
|
||
return raw
|
||
|
||
# 过滤目标人物
|
||
if target_name:
|
||
messages = [
|
||
m for m in messages
|
||
if target_name in str(m.get("sender", {}).get("name", ""))
|
||
]
|
||
|
||
# 分类输出
|
||
long_msgs = [m for m in messages if len(str(m.get("content", ""))) > 50]
|
||
short_msgs = [m for m in messages if len(str(m.get("content", ""))) <= 50]
|
||
|
||
lines = [
|
||
"# 飞书消息记录(MCP 方案)",
|
||
f"群聊 ID:{chat_id}",
|
||
f"目标人物:{target_name or '全部'}",
|
||
f"共 {len(messages)} 条",
|
||
"",
|
||
"---",
|
||
"",
|
||
"## 长消息",
|
||
"",
|
||
]
|
||
for m in long_msgs:
|
||
sender = m.get("sender", {}).get("name", "")
|
||
content = m.get("content", "")
|
||
ts = m.get("create_time", "")
|
||
lines.append(f"[{ts}] {sender}:{content}")
|
||
lines.append("")
|
||
|
||
lines += ["---", "", "## 日常消息", ""]
|
||
for m in short_msgs[:200]:
|
||
sender = m.get("sender", {}).get("name", "")
|
||
content = m.get("content", "")
|
||
lines.append(f"{sender}:{content}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def list_wiki_docs(space_id: str, config: dict) -> str:
|
||
"""列出知识库空间下的所有文档"""
|
||
result = call_mcp("list_wiki_nodes", {"space_id": space_id}, config)
|
||
raw = result.get("result", "")
|
||
if isinstance(raw, str):
|
||
return raw
|
||
return json.dumps(raw, ensure_ascii=False, indent=2)
|
||
|
||
|
||
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="飞书 MCP 客户端")
|
||
parser.add_argument("--setup", action="store_true", help="初始化配置(App ID / Secret)")
|
||
parser.add_argument("--url", help="飞书文档/Wiki/表格链接")
|
||
parser.add_argument("--chat-id", help="群聊 ID(oc_xxx 格式)")
|
||
parser.add_argument("--target", help="目标人物姓名")
|
||
parser.add_argument("--limit", type=int, default=500, help="最多获取消息数")
|
||
parser.add_argument("--list-wiki", action="store_true", help="列出知识库文档")
|
||
parser.add_argument("--space-id", help="知识库 Space ID")
|
||
parser.add_argument("--output", default=None, help="输出文件路径")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.setup:
|
||
setup_config()
|
||
return
|
||
|
||
config = load_config()
|
||
if not config:
|
||
print("错误:尚未配置,请先运行:python3 feishu_mcp_client.py --setup", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
content = ""
|
||
|
||
if args.url:
|
||
print(f"通过 MCP 读取:{args.url}", file=sys.stderr)
|
||
content = fetch_doc_via_mcp(args.url, config)
|
||
|
||
elif args.chat_id:
|
||
print(f"通过 MCP 读取消息:{args.chat_id}", file=sys.stderr)
|
||
content = fetch_messages_via_mcp(
|
||
args.chat_id,
|
||
args.target or "",
|
||
args.limit,
|
||
config,
|
||
)
|
||
|
||
elif args.list_wiki:
|
||
if not args.space_id:
|
||
print("错误:--list-wiki 需要 --space-id", file=sys.stderr)
|
||
sys.exit(1)
|
||
content = list_wiki_docs(args.space_id, config)
|
||
|
||
else:
|
||
parser.print_help()
|
||
return
|
||
|
||
if args.output:
|
||
Path(args.output).write_text(content, encoding="utf-8")
|
||
print(f"✅ 已保存到 {args.output}", file=sys.stderr)
|
||
else:
|
||
print(content)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|