move claude-marketplace to ai-proj-helper
This commit is contained in:
8
plugins/feishu-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/feishu-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "feishu-plugin",
|
||||
"description": "飞书多维表格快捷操作。通过自然语言实现多维表格的增删改查、数据同步、批量操作等功能。当用户提到飞书、多维表格、Bitable、飞书表格相关任务时自动激活。",
|
||||
"version": "1.1.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
322
plugins/feishu-plugin/add_images.py
Normal file
322
plugins/feishu-plugin/add_images.py
Normal file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
添加需求图片字段并上传图片到飞书多维表格
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# Demo 创建的多维表格信息
|
||||
APP_TOKEN = "D6PQbxf4aald77sPjDTciYbenjc"
|
||||
TABLE_ID = "tblX3YbGrXm8pmLR"
|
||||
|
||||
|
||||
class FeishuBitable:
|
||||
"""飞书多维表格操作工具类"""
|
||||
|
||||
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def create_field(self, app_token: str, table_id: str, field_name: str, field_type: int, property: dict = None):
|
||||
"""创建新字段"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
payload = {
|
||||
"field_name": field_name,
|
||||
"type": field_type
|
||||
}
|
||||
if property:
|
||||
payload["property"] = property
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建字段失败: {data}")
|
||||
|
||||
print(f"[OK] 字段 '{field_name}' 创建成功")
|
||||
return data["data"]["field"]
|
||||
|
||||
def upload_media(self, app_token: str, file_path: str, file_name: str = None):
|
||||
"""上传附件到多维表格"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
if file_name is None:
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}"
|
||||
}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {
|
||||
'file': (file_name, f, 'image/png')
|
||||
}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'bitable_image',
|
||||
'parent_node': app_token,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
|
||||
file_token = result["data"]["file_token"]
|
||||
print(f"[OK] 文件 '{file_name}' 上传成功, file_token: {file_token}")
|
||||
return file_token
|
||||
|
||||
def list_records(self, app_token: str, table_id: str, filter_str: str = None):
|
||||
"""列出记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": 100}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"查询失败: {data}")
|
||||
|
||||
return data["data"].get("items", [])
|
||||
|
||||
def update_record(self, app_token: str, table_id: str, record_id: str, fields: dict):
|
||||
"""更新记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||
response = requests.put(url, headers=self.headers, json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"更新失败: {data}")
|
||||
|
||||
print(f"[OK] 记录 {record_id} 更新成功")
|
||||
return data["data"]["record"]
|
||||
|
||||
|
||||
def generate_sample_images(output_dir: str):
|
||||
"""生成2张示例需求图片"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
images = []
|
||||
|
||||
# 图片1: 用户流程图
|
||||
img1 = Image.new('RGB', (800, 600), color='#f0f4f8')
|
||||
draw1 = ImageDraw.Draw(img1)
|
||||
|
||||
# 绘制标题
|
||||
draw1.rectangle([50, 30, 750, 80], fill='#4a90d9', outline='#2563eb')
|
||||
draw1.text((400, 55), "用户登录流程图", fill='white', anchor='mm')
|
||||
|
||||
# 绘制流程框
|
||||
boxes = [
|
||||
(150, 150, "打开登录页"),
|
||||
(400, 150, "输入账号密码"),
|
||||
(650, 150, "点击登录"),
|
||||
(150, 300, "验证账号"),
|
||||
(400, 300, "生成Token"),
|
||||
(650, 300, "跳转首页"),
|
||||
]
|
||||
for x, y, text in boxes:
|
||||
draw1.rectangle([x-60, y-25, x+60, y+25], fill='#e8f4fd', outline='#4a90d9', width=2)
|
||||
draw1.text((x, y), text, fill='#1e3a5f', anchor='mm')
|
||||
|
||||
# 绘制箭头线
|
||||
arrows = [
|
||||
(210, 150, 340, 150),
|
||||
(460, 150, 590, 150),
|
||||
(650, 175, 650, 275),
|
||||
(590, 300, 460, 300),
|
||||
(340, 300, 210, 300),
|
||||
]
|
||||
for x1, y1, x2, y2 in arrows:
|
||||
draw1.line([(x1, y1), (x2, y2)], fill='#4a90d9', width=2)
|
||||
|
||||
# 添加水印
|
||||
draw1.text((400, 550), "Claude Code Demo - 需求图片1", fill='#94a3b8', anchor='mm')
|
||||
|
||||
img1_path = os.path.join(output_dir, "requirement_flow.png")
|
||||
img1.save(img1_path)
|
||||
images.append(img1_path)
|
||||
print(f"[OK] 生成图片: {img1_path}")
|
||||
|
||||
# 图片2: 界面原型图
|
||||
img2 = Image.new('RGB', (800, 600), color='#ffffff')
|
||||
draw2 = ImageDraw.Draw(img2)
|
||||
|
||||
# 绘制浏览器框架
|
||||
draw2.rectangle([50, 30, 750, 570], outline='#d1d5db', width=2)
|
||||
draw2.rectangle([50, 30, 750, 70], fill='#f3f4f6', outline='#d1d5db')
|
||||
|
||||
# 浏览器按钮
|
||||
draw2.ellipse([70, 42, 86, 58], fill='#ef4444')
|
||||
draw2.ellipse([95, 42, 111, 58], fill='#eab308')
|
||||
draw2.ellipse([120, 42, 136, 58], fill='#22c55e')
|
||||
|
||||
# 地址栏
|
||||
draw2.rectangle([160, 42, 600, 58], fill='white', outline='#d1d5db')
|
||||
draw2.text((170, 50), "https://example.com/login", fill='#6b7280', anchor='lm')
|
||||
|
||||
# 登录表单区域
|
||||
draw2.rectangle([200, 120, 600, 500], fill='#f8fafc', outline='#e2e8f0', width=1)
|
||||
|
||||
# Logo 占位
|
||||
draw2.ellipse([350, 140, 450, 200], fill='#4a90d9')
|
||||
draw2.text((400, 170), "LOGO", fill='white', anchor='mm')
|
||||
|
||||
# 标题
|
||||
draw2.text((400, 230), "欢迎登录", fill='#1e293b', anchor='mm')
|
||||
|
||||
# 输入框
|
||||
draw2.rectangle([250, 270, 550, 310], fill='white', outline='#cbd5e1')
|
||||
draw2.text((260, 290), "请输入用户名", fill='#94a3b8', anchor='lm')
|
||||
|
||||
draw2.rectangle([250, 330, 550, 370], fill='white', outline='#cbd5e1')
|
||||
draw2.text((260, 350), "请输入密码", fill='#94a3b8', anchor='lm')
|
||||
|
||||
# 登录按钮
|
||||
draw2.rectangle([250, 400, 550, 450], fill='#4a90d9', outline='#2563eb')
|
||||
draw2.text((400, 425), "登 录", fill='white', anchor='mm')
|
||||
|
||||
# 底部链接
|
||||
draw2.text((320, 480), "忘记密码", fill='#4a90d9', anchor='mm')
|
||||
draw2.text((480, 480), "注册账号", fill='#4a90d9', anchor='mm')
|
||||
|
||||
# 水印
|
||||
draw2.text((400, 550), "Claude Code Demo - 需求图片2", fill='#94a3b8', anchor='mm')
|
||||
|
||||
img2_path = os.path.join(output_dir, "requirement_ui.png")
|
||||
img2.save(img2_path)
|
||||
images.append(img2_path)
|
||||
print(f"[OK] 生成图片: {img2_path}")
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 添加需求图片到多维表格")
|
||||
print("#" * 60)
|
||||
|
||||
bitable = FeishuBitable()
|
||||
|
||||
# Step 1: 生成示例图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 1: 生成示例图片")
|
||||
print("=" * 50)
|
||||
|
||||
output_dir = "/tmp/feishu_demo_images"
|
||||
image_paths = generate_sample_images(output_dir)
|
||||
|
||||
# Step 2: 添加"需求图片"字段
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 2: 添加「需求图片」字段")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
bitable.create_field(
|
||||
APP_TOKEN,
|
||||
TABLE_ID,
|
||||
"需求图片",
|
||||
17 # 17 = 附件类型
|
||||
)
|
||||
except Exception as e:
|
||||
if "FieldNameExist" in str(e) or "FieldNameDuplicated" in str(e) or "1254043" in str(e) or "1254014" in str(e):
|
||||
print("[INFO] 字段「需求图片」已存在,跳过创建")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Step 3: 上传图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 3: 上传图片到飞书")
|
||||
print("=" * 50)
|
||||
|
||||
file_tokens = []
|
||||
for img_path in image_paths:
|
||||
file_token = bitable.upload_media(APP_TOKEN, img_path)
|
||||
file_tokens.append(file_token)
|
||||
|
||||
# Step 4: 查找"完成产品需求文档"记录
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 4: 查找目标记录")
|
||||
print("=" * 50)
|
||||
|
||||
records = bitable.list_records(APP_TOKEN, TABLE_ID)
|
||||
target_record = None
|
||||
for record in records:
|
||||
if record["fields"].get("任务名称") == "完成产品需求文档":
|
||||
target_record = record
|
||||
break
|
||||
|
||||
if not target_record:
|
||||
raise Exception("未找到「完成产品需求文档」记录")
|
||||
|
||||
print(f"[OK] 找到记录: {target_record['record_id']}")
|
||||
print(f" 任务名称: {target_record['fields'].get('任务名称')}")
|
||||
|
||||
# Step 5: 更新记录,添加图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 5: 更新记录,添加图片附件")
|
||||
print("=" * 50)
|
||||
|
||||
# 构造附件字段值
|
||||
attachments = [{"file_token": ft} for ft in file_tokens]
|
||||
|
||||
bitable.update_record(
|
||||
APP_TOKEN,
|
||||
TABLE_ID,
|
||||
target_record["record_id"],
|
||||
{"需求图片": attachments}
|
||||
)
|
||||
|
||||
# 完成
|
||||
print("\n" + "=" * 60)
|
||||
print("完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n已添加 {len(file_tokens)} 张图片到「完成产品需求文档」记录")
|
||||
print(f"\n访问地址查看效果:")
|
||||
print(f" https://zhiyuncai.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
530
plugins/feishu-plugin/aiproj_sync.py
Normal file
530
plugins/feishu-plugin/aiproj_sync.py
Normal file
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ai-proj 与飞书项目同步脚本
|
||||
|
||||
功能:
|
||||
- 初始化:创建飞书根目录、多维表格
|
||||
- 全量同步:同步所有项目到飞书
|
||||
- 增量同步:只同步新增项目
|
||||
- 更新同步:更新现有项目的任务统计等信息
|
||||
|
||||
使用方法:
|
||||
python aiproj_sync.py init # 首次初始化
|
||||
python aiproj_sync.py sync # 增量同步(推荐日常使用)
|
||||
python aiproj_sync.py sync-all # 全量同步
|
||||
python aiproj_sync.py update # 更新统计信息
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
|
||||
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
|
||||
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
|
||||
|
||||
# 默认协作者 open_id(邱栋梁@智云采购)
|
||||
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
|
||||
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
|
||||
|
||||
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
|
||||
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
|
||||
|
||||
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
|
||||
|
||||
# ============================================
|
||||
# 飞书 API
|
||||
# ============================================
|
||||
|
||||
class FeishuAPI:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""获取 tenant_access_token"""
|
||||
if self.token:
|
||||
return self.token
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": FEISHU_APP_ID,
|
||||
"app_secret": FEISHU_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
self.token = data["tenant_access_token"]
|
||||
return self.token
|
||||
else:
|
||||
raise Exception(f"获取飞书 token 失败: {data}")
|
||||
|
||||
def _headers(self, content_type: bool = False) -> Dict:
|
||||
headers = {"Authorization": f"Bearer {self.get_token()}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def get_root_folder(self) -> str:
|
||||
"""获取云空间根文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["token"]
|
||||
raise Exception(f"获取根文件夹失败: {data}")
|
||||
|
||||
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
|
||||
"""创建文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": parent_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
folder_token = data["data"]["token"]
|
||||
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
|
||||
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
|
||||
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
|
||||
return folder_token
|
||||
raise Exception(f"创建文件夹失败: {data}")
|
||||
|
||||
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
|
||||
"""添加协作者
|
||||
|
||||
应用通过 API 创建的文件/文件夹,所有者是应用本身。
|
||||
组织内用户默认无法访问,需要显式添加为协作者。
|
||||
|
||||
Args:
|
||||
file_token: 文件/文件夹 token
|
||||
file_type: 类型 (folder, doc, sheet, bitable 等)
|
||||
user_open_id: 用户 open_id
|
||||
perm: 权限级别 (full_access, edit, view)
|
||||
"""
|
||||
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
|
||||
params = {"type": file_type, "need_notification": "false"}
|
||||
payload = {
|
||||
"member_type": "openid",
|
||||
"member_id": user_open_id,
|
||||
"perm": perm
|
||||
}
|
||||
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def create_bitable(self, name: str, folder_token: str) -> Dict:
|
||||
"""创建多维表格"""
|
||||
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": folder_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["app"]
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
def get_bitable_tables(self, app_token: str) -> List[Dict]:
|
||||
"""获取数据表列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
raise Exception(f"获取数据表失败: {data}")
|
||||
|
||||
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
|
||||
"""创建字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.post(url, headers=self._headers(True), json=field)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["field"]
|
||||
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
|
||||
"""更新字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json=updates)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||
"""获取字段列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
return []
|
||||
|
||||
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
|
||||
"""添加记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["record"]
|
||||
print(f" 记录添加失败: {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
|
||||
"""获取记录列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": 500}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self._headers(), params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"].get("items", [])
|
||||
return []
|
||||
|
||||
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
|
||||
"""更新记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
|
||||
return response.json().get("code") == 0
|
||||
|
||||
# ============================================
|
||||
# ai-proj API
|
||||
# ============================================
|
||||
|
||||
class AIProjAPI:
|
||||
@staticmethod
|
||||
def get_projects() -> List[Dict]:
|
||||
"""获取项目列表"""
|
||||
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
|
||||
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("success"):
|
||||
return data["data"]["data"]
|
||||
raise Exception(f"获取项目列表失败: {data}")
|
||||
|
||||
# ============================================
|
||||
# 配置管理
|
||||
# ============================================
|
||||
|
||||
def load_config() -> Optional[Dict]:
|
||||
"""加载配置"""
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
def save_config(config: Dict):
|
||||
"""保存配置"""
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# ============================================
|
||||
# 同步逻辑
|
||||
# ============================================
|
||||
|
||||
def init():
|
||||
"""初始化:创建飞书目录结构"""
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 初始化")
|
||||
print("=" * 50)
|
||||
|
||||
if load_config():
|
||||
print("\n已存在配置文件,是否重新初始化?(y/N)")
|
||||
if input().lower() != 'y':
|
||||
print("取消初始化")
|
||||
return
|
||||
|
||||
feishu = FeishuAPI()
|
||||
|
||||
# 1. 获取根目录
|
||||
print("\n[1/5] 获取云空间根目录...")
|
||||
space_root = feishu.get_root_folder()
|
||||
print(f" 根目录: {space_root}")
|
||||
|
||||
# 2. 创建 ai-proj 文件夹
|
||||
print("\n[2/5] 创建 ai-proj 根文件夹...")
|
||||
root_folder = feishu.create_folder("ai-proj", space_root)
|
||||
print(f" 创建成功: {root_folder}")
|
||||
|
||||
# 3. 创建多维表格
|
||||
print("\n[3/5] 创建项目目录多维表格...")
|
||||
bitable = feishu.create_bitable("项目目录", root_folder)
|
||||
app_token = bitable["app_token"]
|
||||
print(f" 创建成功: {app_token}")
|
||||
|
||||
tables = feishu.get_bitable_tables(app_token)
|
||||
table_id = tables[0]["table_id"]
|
||||
|
||||
# 4. 创建字段
|
||||
print("\n[4/5] 创建数据表字段...")
|
||||
|
||||
# 先修改第一列名称
|
||||
fields = feishu.get_fields(app_token, table_id)
|
||||
if fields:
|
||||
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
|
||||
"field_name": "项目名称",
|
||||
"type": fields[0]["type"]
|
||||
})
|
||||
print(" 修改字段: 项目名称")
|
||||
|
||||
# 创建其他字段
|
||||
field_definitions = [
|
||||
{"field_name": "项目ID", "type": 2},
|
||||
{"field_name": "项目编号", "type": 1},
|
||||
{"field_name": "飞书文件夹", "type": 15},
|
||||
{"field_name": "状态", "type": 3, "property": {"options": [
|
||||
{"name": "active", "color": 0},
|
||||
{"name": "on_hold", "color": 1},
|
||||
{"name": "planning", "color": 2},
|
||||
{"name": "completed", "color": 3},
|
||||
{"name": "archived", "color": 4}
|
||||
]}},
|
||||
{"field_name": "优先级", "type": 3, "property": {"options": [
|
||||
{"name": "high", "color": 0},
|
||||
{"name": "medium", "color": 1},
|
||||
{"name": "low", "color": 2}
|
||||
]}},
|
||||
{"field_name": "所属企业", "type": 1},
|
||||
{"field_name": "任务总数", "type": 2},
|
||||
{"field_name": "描述", "type": 1},
|
||||
{"field_name": "创建时间", "type": 5},
|
||||
{"field_name": "最后同步", "type": 5},
|
||||
]
|
||||
|
||||
for field in field_definitions:
|
||||
if feishu.create_field(app_token, table_id, field):
|
||||
print(f" 创建字段: {field['field_name']}")
|
||||
|
||||
# 5. 保存配置
|
||||
print("\n[5/5] 保存配置...")
|
||||
config = {
|
||||
"root_folder_token": root_folder,
|
||||
"bitable_app_token": app_token,
|
||||
"bitable_table_id": table_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"synced_projects": {}
|
||||
}
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("初始化完成!")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
|
||||
print(f"多维表格: https://feishu.cn/base/{app_token}")
|
||||
print(f"\n配置文件: {CONFIG_PATH}")
|
||||
print("\n运行 'python aiproj_sync.py sync' 同步项目")
|
||||
|
||||
def sync(full: bool = False):
|
||||
"""同步项目"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
root_folder = config["root_folder_token"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取 ai-proj 项目
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
print(f" 共 {len(projects)} 个项目")
|
||||
|
||||
# 筛选需要同步的项目
|
||||
if full:
|
||||
to_sync = projects
|
||||
else:
|
||||
to_sync = [p for p in projects if str(p["id"]) not in synced]
|
||||
|
||||
if not to_sync:
|
||||
print("\n没有新项目需要同步")
|
||||
return
|
||||
|
||||
print(f"\n需要同步 {len(to_sync)} 个项目...")
|
||||
|
||||
success = 0
|
||||
for project in to_sync:
|
||||
project_id = str(project["id"])
|
||||
project_name = project["name"]
|
||||
folder_name = f"{project_name}_{project_id}"
|
||||
|
||||
print(f"\n 处理: {project_name} (ID: {project_id})")
|
||||
|
||||
# 创建文件夹(如果不存在)
|
||||
folder_token = synced.get(project_id, {}).get("folder_token")
|
||||
if not folder_token:
|
||||
try:
|
||||
folder_token = feishu.create_folder(folder_name, root_folder)
|
||||
print(f" 创建文件夹: {folder_token}")
|
||||
except Exception as e:
|
||||
print(f" 文件夹创建失败: {e}")
|
||||
folder_token = ""
|
||||
|
||||
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
|
||||
|
||||
# 添加/更新记录
|
||||
record_fields = {
|
||||
"项目名称": project_name,
|
||||
"项目ID": int(project_id),
|
||||
"项目编号": project.get("project_number", ""),
|
||||
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
|
||||
"状态": project.get("status", "active"),
|
||||
"优先级": project.get("priority", "medium"),
|
||||
"所属企业": project.get("company_name", ""),
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"描述": (project.get("description", "") or "")[:500],
|
||||
"创建时间": int(datetime.fromisoformat(
|
||||
project["created_at"].replace("Z", "+00:00")
|
||||
).timestamp() * 1000),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
record_id = synced.get(project_id, {}).get("record_id")
|
||||
if record_id and not full:
|
||||
# 更新现有记录
|
||||
if feishu.update_record(app_token, table_id, record_id, record_fields):
|
||||
print(f" 更新记录成功")
|
||||
success += 1
|
||||
else:
|
||||
# 添加新记录
|
||||
record = feishu.add_record(app_token, table_id, record_fields)
|
||||
if record:
|
||||
record_id = record["record_id"]
|
||||
print(f" 添加记录成功")
|
||||
success += 1
|
||||
|
||||
# 更新同步记录
|
||||
synced[project_id] = {
|
||||
"folder_token": folder_token,
|
||||
"folder_url": folder_url,
|
||||
"record_id": record_id,
|
||||
"synced_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 保存配置
|
||||
config["synced_projects"] = synced
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"同步完成: {success}/{len(to_sync)}")
|
||||
print("=" * 50)
|
||||
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
|
||||
|
||||
def update_stats():
|
||||
"""更新任务统计信息"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 更新统计")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取最新项目数据
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
project_map = {str(p["id"]): p for p in projects}
|
||||
|
||||
updated = 0
|
||||
for project_id, sync_info in synced.items():
|
||||
record_id = sync_info.get("record_id")
|
||||
if not record_id:
|
||||
continue
|
||||
|
||||
project = project_map.get(project_id)
|
||||
if not project:
|
||||
continue
|
||||
|
||||
# 只更新统计字段
|
||||
update_fields = {
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"状态": project.get("status", "active"),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
if feishu.update_record(app_token, table_id, record_id, update_fields):
|
||||
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
|
||||
updated += 1
|
||||
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print(f"\n更新完成: {updated} 个项目")
|
||||
|
||||
def show_status():
|
||||
"""显示同步状态"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未初始化,请先运行: python aiproj_sync.py init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("ai-proj 飞书同步状态")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
|
||||
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
|
||||
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
|
||||
print(f"上次同步: {config.get('last_sync', '从未')}")
|
||||
print(f"配置文件: {CONFIG_PATH}")
|
||||
|
||||
# ============================================
|
||||
# 主入口
|
||||
# ============================================
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python aiproj_sync.py <command>")
|
||||
print("\n命令:")
|
||||
print(" init 首次初始化(创建目录和多维表格)")
|
||||
print(" sync 增量同步(只同步新项目)")
|
||||
print(" sync-all 全量同步(同步所有项目)")
|
||||
print(" update 更新统计信息")
|
||||
print(" status 查看同步状态")
|
||||
return
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "init":
|
||||
init()
|
||||
elif command == "sync":
|
||||
sync(full=False)
|
||||
elif command == "sync-all":
|
||||
sync(full=True)
|
||||
elif command == "update":
|
||||
update_stats()
|
||||
elif command == "status":
|
||||
show_status()
|
||||
else:
|
||||
print(f"未知命令: {command}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
104
plugins/feishu-plugin/check_docx_image.py
Normal file
104
plugins/feishu-plugin/check_docx_image.py
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
检查飞书云文档中的图片块状态
|
||||
"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 最近创建的测试文档
|
||||
DOCUMENT_ID = "Z53YdDpezob1NPx63sQcsrt8nzd"
|
||||
|
||||
|
||||
def get_token():
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
return data["tenant_access_token"]
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
token = get_token()
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n检查文档: {DOCUMENT_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
blocks = get_document_blocks(DOCUMENT_ID)
|
||||
|
||||
print(f"\n文档共有 {len(blocks)} 个块:\n")
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
block_type = block.get("block_type")
|
||||
block_id = block.get("block_id")
|
||||
|
||||
# 块类型映射
|
||||
type_names = {
|
||||
1: "page",
|
||||
2: "text",
|
||||
3: "heading1",
|
||||
4: "heading2",
|
||||
5: "heading3",
|
||||
12: "bullet",
|
||||
13: "ordered",
|
||||
14: "code",
|
||||
17: "todo",
|
||||
22: "divider",
|
||||
27: "image",
|
||||
}
|
||||
type_name = type_names.get(block_type, f"type_{block_type}")
|
||||
|
||||
print(f" [{i}] block_type={block_type} ({type_name}), block_id={block_id[:20]}...")
|
||||
|
||||
# 如果是图片块,显示详细信息
|
||||
if block_type == 27:
|
||||
image_data = block.get("image", {})
|
||||
print(f" image data: {image_data}")
|
||||
|
||||
# 检查图片是否有效
|
||||
file_token = image_data.get("token") or image_data.get("file_token")
|
||||
if file_token:
|
||||
print(f" file_token: {file_token}")
|
||||
# 尝试获取图片信息
|
||||
check_image_status(file_token)
|
||||
else:
|
||||
print(f" [WARN] 图片块没有 token!")
|
||||
|
||||
|
||||
def check_image_status(file_token: str):
|
||||
"""检查图片状态"""
|
||||
token = get_token()
|
||||
|
||||
# 尝试获取文件元信息
|
||||
url = f"{BASE_URL}/drive/v1/medias/{file_token}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
print(f" 图片状态: code={data.get('code')}, msg={data.get('msg')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
51
plugins/feishu-plugin/check_manual_images.py
Normal file
51
plugins/feishu-plugin/check_manual_images.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查文档中的图片块状态"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||
|
||||
def get_token():
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
return response.json()["tenant_access_token"]
|
||||
|
||||
def main():
|
||||
token = get_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
url = f"{BASE_URL}/docx/v1/documents/{DOCUMENT_ID}/blocks"
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
print(f"获取失败: {data}")
|
||||
return
|
||||
|
||||
blocks = data["data"].get("items", [])
|
||||
print(f"文档共有 {len(blocks)} 个块\n")
|
||||
|
||||
image_count = 0
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_count += 1
|
||||
image_data = block.get("image", {})
|
||||
token_value = image_data.get("token", "")
|
||||
print(f"图片块 #{image_count}:")
|
||||
print(f" block_id: {block.get('block_id')}")
|
||||
print(f" token: {token_value if token_value else '(空)'}")
|
||||
print(f" width: {image_data.get('width')}, height: {image_data.get('height')}")
|
||||
print()
|
||||
|
||||
if image_count == 0:
|
||||
print("文档中没有图片块")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
423
plugins/feishu-plugin/create_visibility_manual.py
Normal file
423
plugins/feishu-plugin/create_visibility_manual.py
Normal file
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建新的飞书文档:ai-proj 项目可见性手册
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def create_document(title: str):
|
||||
"""创建文档"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
response = requests.post(url, headers=headers(), json={"title": title})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
print(f"[OK] 文档创建成功: {document_id}")
|
||||
return document_id
|
||||
|
||||
|
||||
def set_document_permission(document_id: str):
|
||||
"""设置文档权限为组织内可编辑"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable",
|
||||
"invite_external": False
|
||||
}
|
||||
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
print("[OK] 权限设置成功: 组织内可编辑")
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] 权限设置: {result.get('msg')}")
|
||||
return False
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||
"""创建内容块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {"children": blocks}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建块失败: {data}")
|
||||
return data["data"].get("children", [])
|
||||
|
||||
|
||||
def create_image_block(document_id: str, parent_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {"replace_image": {"token": file_token}}
|
||||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||||
"""插入图片的完整流程:创建块 -> 上传 -> 绑定"""
|
||||
print(f" 插入图片: {description}")
|
||||
|
||||
# Step 1: 创建空图片块
|
||||
block_id = create_image_block(document_id, document_id)
|
||||
print(f" block_id: {block_id}")
|
||||
|
||||
# Step 2: 上传图片
|
||||
file_token = upload_image(file_path, block_id)
|
||||
print(f" file_token: {file_token}")
|
||||
|
||||
# Step 3: 绑定图片到块
|
||||
bind_image(document_id, block_id, file_token)
|
||||
print(f" 绑定成功!")
|
||||
|
||||
# 等待处理
|
||||
time.sleep(0.5)
|
||||
|
||||
return block_id, file_token
|
||||
|
||||
|
||||
def generate_visibility_diagram():
|
||||
"""生成可见性示意图"""
|
||||
output_path = "/tmp/visibility_diagram.png"
|
||||
|
||||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 尝试加载字体
|
||||
try:
|
||||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||||
except:
|
||||
font_large = ImageFont.load_default()
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题背景
|
||||
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||||
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||||
|
||||
# 私有项目区域
|
||||
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||||
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||||
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||||
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 私有项目图标
|
||||
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||||
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||||
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||||
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目区域
|
||||
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||||
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||||
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||||
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目图标组
|
||||
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||||
for x, y in positions:
|
||||
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||||
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||||
|
||||
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||||
|
||||
# 中间箭头
|
||||
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_ui_screenshot():
|
||||
"""生成 UI 示意图"""
|
||||
output_path = "/tmp/visibility_ui.png"
|
||||
|
||||
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||||
except:
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题
|
||||
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||||
|
||||
# 私有项目选项(选中状态)
|
||||
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||||
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||||
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||||
|
||||
# 默认标签
|
||||
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||||
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目选项(未选中状态)
|
||||
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||||
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||||
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||||
|
||||
# 底部说明
|
||||
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||||
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||||
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# 文档内容块定义
|
||||
def heading1(text):
|
||||
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
def heading2(text):
|
||||
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
def heading3(text):
|
||||
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
def text_block(content):
|
||||
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
def bullet(content):
|
||||
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
def ordered(content):
|
||||
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
def code_block(content, language=1):
|
||||
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
|
||||
|
||||
def divider():
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 创建新文档:ai-proj 项目可见性手册")
|
||||
print("#" * 60)
|
||||
|
||||
# Step 1: 生成示意图
|
||||
print("\n--- Step 1: 生成示意图 ---")
|
||||
diagram_path = generate_visibility_diagram()
|
||||
ui_path = generate_ui_screenshot()
|
||||
|
||||
# Step 2: 创建新文档
|
||||
print("\n--- Step 2: 创建新文档 ---")
|
||||
doc_id = create_document("ai-proj 项目可见性手册")
|
||||
set_document_permission(doc_id)
|
||||
|
||||
# Step 3: 创建手册内容
|
||||
print("\n--- Step 3: 创建手册内容 ---")
|
||||
|
||||
# 第一部分:标题和概述
|
||||
blocks_part1 = [
|
||||
heading1("ai-proj 项目可见性手册"),
|
||||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||
divider(),
|
||||
heading2("1. 功能概述"),
|
||||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||
divider(),
|
||||
heading2("2. 可见性对比"),
|
||||
text_block("下图展示了两种可见性的区别:"),
|
||||
]
|
||||
create_blocks(doc_id, doc_id, blocks_part1)
|
||||
print(" 第一部分内容创建完成")
|
||||
|
||||
# 插入可见性示意图
|
||||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||
insert_image(doc_id, diagram_path, "可见性示意图")
|
||||
|
||||
# 第二部分:设置方法
|
||||
print("\n--- Step 5: 添加设置说明 ---")
|
||||
blocks_part2 = [
|
||||
divider(),
|
||||
heading2("3. 设置项目可见性"),
|
||||
heading3("3.1 创建项目时设置"),
|
||||
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||||
ordered("点击「创建项目」按钮"),
|
||||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||
ordered("选择「私有项目」或「企业项目」"),
|
||||
ordered("填写其他信息后点击「确定」"),
|
||||
text_block("UI 界面示意:"),
|
||||
]
|
||||
create_blocks(doc_id, doc_id, blocks_part2)
|
||||
print(" 设置说明创建完成")
|
||||
|
||||
# 插入 UI 示意图
|
||||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||
insert_image(doc_id, ui_path, "UI 示意图")
|
||||
|
||||
# 第三部分:修改方法和规则
|
||||
print("\n--- Step 7: 添加剩余内容 ---")
|
||||
blocks_part3 = [
|
||||
heading3("3.2 修改已有项目"),
|
||||
ordered("在项目列表中找到目标项目"),
|
||||
ordered("点击项目卡片上的「编辑」按钮"),
|
||||
ordered("在编辑页面修改「项目可见性」"),
|
||||
ordered("点击「保存」"),
|
||||
divider(),
|
||||
heading2("4. 可见性规则"),
|
||||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||
divider(),
|
||||
heading2("5. API 参考"),
|
||||
text_block("修改项目可见性的 API:"),
|
||||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||
|
||||
请求体:
|
||||
{
|
||||
"visibility": "enterprise"
|
||||
}
|
||||
|
||||
可选值: "private" | "enterprise"'''),
|
||||
divider(),
|
||||
heading2("6. 注意事项"),
|
||||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||
divider(),
|
||||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||
]
|
||||
create_blocks(doc_id, doc_id, blocks_part3)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("文档创建完成!")
|
||||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{doc_id}")
|
||||
print("=" * 60)
|
||||
|
||||
# 验证图片
|
||||
print("\n--- 验证图片状态 ---")
|
||||
time.sleep(2) # 等待服务器处理
|
||||
blocks = get_document_blocks(doc_id)
|
||||
image_count = 0
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_count += 1
|
||||
image_data = block.get("image", {})
|
||||
token = image_data.get("token", "")
|
||||
width = image_data.get("width", 0)
|
||||
height = image_data.get("height", 0)
|
||||
status = "✅ 有效" if token else "❌ 空"
|
||||
print(f"图片 #{image_count}: {status} (token: {token[:15]}..., 尺寸: {width}x{height})")
|
||||
|
||||
if image_count == 0:
|
||||
print("⚠️ 警告:文档中没有图片块")
|
||||
elif image_count == 2:
|
||||
print(f"\n✅ 成功上传 {image_count} 张图片")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
239
plugins/feishu-plugin/debug_image.py
Normal file
239
plugins/feishu-plugin/debug_image.py
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
深入调试飞书云文档图片上传
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def set_document_permission(document_id: str, editable: bool = True):
|
||||
"""设置文档权限"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
|
||||
def create_document(title: str, editable: bool = True):
|
||||
"""创建文档(自动设置为组织内可编辑)"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
response = requests.post(url, headers=headers(), json={"title": title})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
print(f"[OK] 文档创建成功: {document_id}")
|
||||
|
||||
# 自动设置权限
|
||||
if editable and set_document_permission(document_id, True):
|
||||
print(f"[OK] 权限设置成功: 组织内可编辑")
|
||||
|
||||
return document_id
|
||||
|
||||
|
||||
def get_raw_content(document_id: str):
|
||||
"""获取文档原始内容"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/raw_content"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
print(f"[DEBUG] raw_content 响应: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def get_blocks(document_id: str):
|
||||
"""获取文档块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
|
||||
# 尝试添加 document_revision_id 参数
|
||||
params = {"document_revision_id": -1} # -1 表示最新版本
|
||||
|
||||
response = requests.get(url, headers=headers(), params=params)
|
||||
data = response.json()
|
||||
return data
|
||||
|
||||
|
||||
def create_image_block(document_id: str):
|
||||
"""创建图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||
|
||||
# 添加 document_revision_id 参数
|
||||
params = {"document_revision_id": -1}
|
||||
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers(), params=params, json=payload)
|
||||
data = response.json()
|
||||
print(f"[DEBUG] 创建图片块响应: {data}")
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
|
||||
block_id = data["data"]["children"][0]["block_id"]
|
||||
print(f"[OK] 图片块创建成功: {block_id}")
|
||||
return block_id
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 上传响应: {result}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image_to_block(document_id: str, block_id: str, file_token: str):
|
||||
"""
|
||||
绑定图片到图片块 (关键步骤!)
|
||||
|
||||
使用 replace_image 字段通过 PATCH 请求绑定
|
||||
"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
|
||||
params = {"document_revision_id": -1}
|
||||
|
||||
# 正确的 payload: 使用 replace_image
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
|
||||
print(f"[INFO] 绑定图片: PATCH {url}")
|
||||
print(f"[INFO] payload: {payload}")
|
||||
|
||||
response = requests.patch(url, headers=headers(), params=params, json=payload)
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 绑定响应: {result}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
print(f"[OK] 图片绑定成功!")
|
||||
return True
|
||||
else:
|
||||
print(f"[ERROR] 图片绑定失败: code={result.get('code')}, msg={result.get('msg')}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 深入调试飞书云文档图片上传")
|
||||
print("#" * 60)
|
||||
|
||||
# 生成测试图片
|
||||
from PIL import Image, ImageDraw
|
||||
test_image = "/tmp/debug_test_image.png"
|
||||
img = Image.new('RGB', (200, 150), color='#4a90d9')
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((100, 75), "Test", fill='white', anchor='mm')
|
||||
img.save(test_image)
|
||||
print(f"[OK] 测试图片: {test_image}")
|
||||
|
||||
# Step 1: 创建文档
|
||||
print("\n--- Step 1: 创建文档 ---")
|
||||
doc_id = create_document(f"调试图片上传 - {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Step 2: 创建图片块
|
||||
print("\n--- Step 2: 创建图片块 ---")
|
||||
block_id = create_image_block(doc_id)
|
||||
|
||||
# Step 3: 上传图片
|
||||
print("\n--- Step 3: 上传图片 ---")
|
||||
file_token = upload_image(test_image, block_id)
|
||||
print(f"[OK] file_token: {file_token}")
|
||||
|
||||
# Step 4: 检查块状态
|
||||
print("\n--- Step 4: 检查块状态 ---")
|
||||
blocks = get_blocks(doc_id)
|
||||
for item in blocks.get("data", {}).get("items", []):
|
||||
if item.get("block_type") == 27:
|
||||
print(f"[INFO] 图片块: {item}")
|
||||
|
||||
# Step 5: 绑定图片到图片块 (关键步骤!)
|
||||
print("\n--- Step 5: 绑定图片到图片块 ---")
|
||||
bind_image_to_block(doc_id, block_id, file_token)
|
||||
|
||||
# Step 6: 再次检查
|
||||
print("\n--- Step 6: 再次检查块状态 ---")
|
||||
import time
|
||||
time.sleep(2) # 等待2秒
|
||||
blocks = get_blocks(doc_id)
|
||||
for item in blocks.get("data", {}).get("items", []):
|
||||
if item.get("block_type") == 27:
|
||||
print(f"[INFO] 图片块: {item}")
|
||||
|
||||
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
409
plugins/feishu-plugin/demo.py
Normal file
409
plugins/feishu-plugin/demo.py
Normal file
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
飞书多维表格 Demo
|
||||
使用 zhiyun.ai 凭证演示多维表格的完整操作流程
|
||||
"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
|
||||
# ========== 工具类 ==========
|
||||
class FeishuBitable:
|
||||
"""飞书多维表格操作工具类"""
|
||||
|
||||
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
"""获取或刷新 access token"""
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
print(f"[OK] Token 获取成功,有效期至 {self._token_expires}")
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ========== 多维表格管理 ==========
|
||||
def create_bitable(self, name: str, folder_token: str = None) -> Dict:
|
||||
"""
|
||||
创建新的多维表格
|
||||
|
||||
Args:
|
||||
name: 多维表格名称
|
||||
folder_token: 文件夹 token(可选,不指定则创建在根目录)
|
||||
"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps"
|
||||
payload = {"name": name}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
app_info = data["data"]["app"]
|
||||
print(f"[OK] 多维表格创建成功")
|
||||
print(f" 名称: {app_info['name']}")
|
||||
print(f" app_token: {app_info['app_token']}")
|
||||
print(f" URL: {app_info.get('url', 'N/A')}")
|
||||
return app_info
|
||||
|
||||
def list_tables(self, app_token: str) -> List[Dict]:
|
||||
"""列出多维表格中的所有数据表"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取数据表列表失败: {data}")
|
||||
|
||||
tables = data["data"].get("items", [])
|
||||
print(f"[OK] 找到 {len(tables)} 个数据表")
|
||||
for t in tables:
|
||||
print(f" - {t['name']} (table_id: {t['table_id']})")
|
||||
return tables
|
||||
|
||||
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
|
||||
"""
|
||||
在多维表格中创建数据表
|
||||
|
||||
Args:
|
||||
app_token: 多维表格 app_token
|
||||
name: 数据表名称
|
||||
fields: 字段定义列表
|
||||
"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||
payload = {
|
||||
"table": {
|
||||
"name": name,
|
||||
"default_view_name": "默认视图",
|
||||
"fields": fields
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建数据表失败: {data}")
|
||||
|
||||
table_info = data["data"]
|
||||
print(f"[OK] 数据表创建成功")
|
||||
print(f" 名称: {name}")
|
||||
print(f" table_id: {table_info['table_id']}")
|
||||
return table_info
|
||||
|
||||
# ========== 记录操作 ==========
|
||||
def list_records(self, app_token: str, table_id: str,
|
||||
filter_str: str = None, page_size: int = 100) -> List[Dict]:
|
||||
"""列出所有记录(自动分页)"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
all_records = []
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
params = {"page_size": min(page_size, 500)}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"查询失败: {data}")
|
||||
|
||||
items = data["data"].get("items", [])
|
||||
all_records.extend(items)
|
||||
|
||||
if not data["data"].get("has_more"):
|
||||
break
|
||||
page_token = data["data"]["page_token"]
|
||||
|
||||
return all_records
|
||||
|
||||
def create_record(self, app_token: str, table_id: str, fields: Dict) -> Dict:
|
||||
"""创建单条记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=self.headers, json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建失败: {data}")
|
||||
return data["data"]["record"]
|
||||
|
||||
def batch_create(self, app_token: str, table_id: str,
|
||||
records: List[Dict], batch_size: int = 500) -> List[Dict]:
|
||||
"""批量创建记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
||||
created = []
|
||||
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = [{"fields": r} if "fields" not in r else r for r in records[i:i+batch_size]]
|
||||
response = requests.post(url, headers=self.headers, json={"records": batch})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"批量创建失败: {data}")
|
||||
created.extend(data["data"]["records"])
|
||||
|
||||
return created
|
||||
|
||||
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||
"""获取字段定义"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取字段失败: {data}")
|
||||
return data["data"]["items"]
|
||||
|
||||
|
||||
# ========== Demo 函数 ==========
|
||||
def demo_verify_credentials():
|
||||
"""验证凭证是否有效"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 1: 验证飞书应用凭证")
|
||||
print("="*50)
|
||||
|
||||
bitable = FeishuBitable()
|
||||
token = bitable.token # 触发 token 获取
|
||||
print(f" Token 前缀: {token[:20]}...")
|
||||
return bitable
|
||||
|
||||
|
||||
def demo_create_bitable(bitable: FeishuBitable) -> str:
|
||||
"""创建新的多维表格"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 2: 创建多维表格")
|
||||
print("="*50)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
name = f"Claude Code Demo - {timestamp}"
|
||||
|
||||
app_info = bitable.create_bitable(name)
|
||||
return app_info["app_token"]
|
||||
|
||||
|
||||
def demo_create_task_table(bitable: FeishuBitable, app_token: str) -> str:
|
||||
"""创建任务管理数据表"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 3: 创建任务管理数据表")
|
||||
print("="*50)
|
||||
|
||||
# 定义字段
|
||||
fields = [
|
||||
{
|
||||
"field_name": "任务名称",
|
||||
"type": 1 # 文本
|
||||
},
|
||||
{
|
||||
"field_name": "状态",
|
||||
"type": 3, # 单选
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "待处理", "color": 0},
|
||||
{"name": "进行中", "color": 1},
|
||||
{"name": "已完成", "color": 2},
|
||||
{"name": "已取消", "color": 3}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "优先级",
|
||||
"type": 3, # 单选
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "高", "color": 4},
|
||||
{"name": "中", "color": 5},
|
||||
{"name": "低", "color": 6}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "负责人",
|
||||
"type": 1 # 文本
|
||||
},
|
||||
{
|
||||
"field_name": "截止日期",
|
||||
"type": 5, # 日期
|
||||
"property": {
|
||||
"date_formatter": "yyyy/MM/dd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "工时(小时)",
|
||||
"type": 2 # 数字
|
||||
},
|
||||
{
|
||||
"field_name": "备注",
|
||||
"type": 1 # 文本
|
||||
}
|
||||
]
|
||||
|
||||
table_info = bitable.create_table(app_token, "任务看板", fields)
|
||||
return table_info["table_id"]
|
||||
|
||||
|
||||
def demo_add_sample_data(bitable: FeishuBitable, app_token: str, table_id: str):
|
||||
"""添加示例数据"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 4: 添加示例数据")
|
||||
print("="*50)
|
||||
|
||||
# 准备示例数据
|
||||
now = datetime.now()
|
||||
sample_tasks = [
|
||||
{
|
||||
"任务名称": "完成产品需求文档",
|
||||
"状态": "已完成",
|
||||
"优先级": "高",
|
||||
"负责人": "张三",
|
||||
"截止日期": int((now - timedelta(days=2)).timestamp() * 1000),
|
||||
"工时(小时)": 8,
|
||||
"备注": "PRD 已评审通过"
|
||||
},
|
||||
{
|
||||
"任务名称": "设计系统架构方案",
|
||||
"状态": "进行中",
|
||||
"优先级": "高",
|
||||
"负责人": "李四",
|
||||
"截止日期": int((now + timedelta(days=3)).timestamp() * 1000),
|
||||
"工时(小时)": 16,
|
||||
"备注": "正在编写技术方案"
|
||||
},
|
||||
{
|
||||
"任务名称": "开发用户登录模块",
|
||||
"状态": "待处理",
|
||||
"优先级": "中",
|
||||
"负责人": "王五",
|
||||
"截止日期": int((now + timedelta(days=7)).timestamp() * 1000),
|
||||
"工时(小时)": 24,
|
||||
"备注": "等待架构方案确定"
|
||||
},
|
||||
{
|
||||
"任务名称": "编写单元测试",
|
||||
"状态": "待处理",
|
||||
"优先级": "中",
|
||||
"负责人": "赵六",
|
||||
"截止日期": int((now + timedelta(days=10)).timestamp() * 1000),
|
||||
"工时(小时)": 12,
|
||||
"备注": ""
|
||||
},
|
||||
{
|
||||
"任务名称": "部署测试环境",
|
||||
"状态": "待处理",
|
||||
"优先级": "低",
|
||||
"负责人": "钱七",
|
||||
"截止日期": int((now + timedelta(days=14)).timestamp() * 1000),
|
||||
"工时(小时)": 4,
|
||||
"备注": "需要申请服务器资源"
|
||||
}
|
||||
]
|
||||
|
||||
# 批量创建记录
|
||||
created = bitable.batch_create(app_token, table_id, sample_tasks)
|
||||
print(f"[OK] 成功创建 {len(created)} 条示例记录")
|
||||
|
||||
# 显示创建的记录
|
||||
for i, record in enumerate(created, 1):
|
||||
fields = record["fields"]
|
||||
print(f" {i}. {fields.get('任务名称')} - {fields.get('状态')} ({fields.get('优先级')}优先级)")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def demo_query_data(bitable: FeishuBitable, app_token: str, table_id: str):
|
||||
"""查询数据演示"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 5: 查询数据演示")
|
||||
print("="*50)
|
||||
|
||||
# 查询所有记录
|
||||
all_records = bitable.list_records(app_token, table_id)
|
||||
print(f"[OK] 共 {len(all_records)} 条记录")
|
||||
|
||||
# 获取字段定义
|
||||
fields = bitable.get_fields(app_token, table_id)
|
||||
print(f"[OK] 共 {len(fields)} 个字段:")
|
||||
for f in fields:
|
||||
print(f" - {f['field_name']} (类型: {f['type']})")
|
||||
|
||||
return all_records
|
||||
|
||||
|
||||
def main():
|
||||
"""运行完整 Demo"""
|
||||
print("\n" + "#"*60)
|
||||
print("# 飞书多维表格 Demo - zhiyun.ai")
|
||||
print("#"*60)
|
||||
|
||||
try:
|
||||
# Step 1: 验证凭证
|
||||
bitable = demo_verify_credentials()
|
||||
|
||||
# Step 2: 创建多维表格
|
||||
app_token = demo_create_bitable(bitable)
|
||||
|
||||
# Step 3: 创建数据表
|
||||
table_id = demo_create_task_table(bitable, app_token)
|
||||
|
||||
# Step 4: 添加示例数据
|
||||
demo_add_sample_data(bitable, app_token, table_id)
|
||||
|
||||
# Step 5: 查询数据
|
||||
demo_query_data(bitable, app_token, table_id)
|
||||
|
||||
# 完成
|
||||
print("\n" + "="*60)
|
||||
print("Demo 完成!")
|
||||
print("="*60)
|
||||
print(f"\n多维表格信息:")
|
||||
print(f" app_token: {app_token}")
|
||||
print(f" table_id: {table_id}")
|
||||
print(f"\n访问地址:")
|
||||
print(f" https://zhiyun-ai.feishu.cn/base/{app_token}?table={table_id}")
|
||||
print()
|
||||
|
||||
return app_token, table_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
451
plugins/feishu-plugin/feishu_docx.py
Normal file
451
plugins/feishu-plugin/feishu_docx.py
Normal file
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
飞书云文档操作工具类
|
||||
包含文档创建、内容块管理、图片上传等功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class FeishuDocx:
|
||||
"""飞书云文档操作工具类(含图片上传)"""
|
||||
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
DEFAULT_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
DEFAULT_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
|
||||
def __init__(self, app_id: str = None, app_secret: str = None):
|
||||
self.app_id = app_id or os.environ.get("FEISHU_APP_ID") or self.DEFAULT_APP_ID
|
||||
self.app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET") or self.DEFAULT_APP_SECRET
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
"""获取或刷新 access token"""
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{self.BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self) -> dict:
|
||||
"""获取请求头"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ==================== 文档管理 ====================
|
||||
|
||||
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
|
||||
"""
|
||||
设置文档权限
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
editable: True=组织内可编辑, False=组织内只读
|
||||
"""
|
||||
url = f"{self.BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
|
||||
"""
|
||||
创建文档(自动设置为组织内可编辑)
|
||||
|
||||
Args:
|
||||
title: 文档标题
|
||||
folder_token: 文件夹token (可选)
|
||||
editable: 是否设置为组织内可编辑 (默认True)
|
||||
|
||||
Returns:
|
||||
dict: {"document_id": "xxx", "title": "xxx", "url": "xxx"}
|
||||
"""
|
||||
url = f"{self.BASE_URL}/docx/v1/documents"
|
||||
payload = {"title": title}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
|
||||
if editable:
|
||||
self.set_document_permission(document_id, editable=True)
|
||||
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"title": doc["title"],
|
||||
"url": f"https://feishu.cn/docx/{document_id}"
|
||||
}
|
||||
|
||||
def get_document_blocks(self, document_id: str, page_size: int = 500) -> List[Dict]:
|
||||
"""获取文档所有内容块"""
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
all_blocks = []
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
params = {"page_size": page_size}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取内容块失败: {data}")
|
||||
|
||||
all_blocks.extend(data["data"].get("items", []))
|
||||
|
||||
if not data["data"].get("has_more"):
|
||||
break
|
||||
page_token = data["data"]["page_token"]
|
||||
|
||||
return all_blocks
|
||||
|
||||
# ==================== 内容块操作 ====================
|
||||
|
||||
def create_blocks(self, document_id: str, blocks: list, parent_id: str = None) -> List[Dict]:
|
||||
"""
|
||||
创建内容块
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
blocks: 内容块列表
|
||||
parent_id: 父块ID (默认为文档根块)
|
||||
"""
|
||||
parent_id = parent_id or document_id
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params={"document_revision_id": -1},
|
||||
json={"children": blocks}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建内容块失败: {data}")
|
||||
|
||||
return data["data"]["children"]
|
||||
|
||||
# ==================== 图片上传 (三步流程) ====================
|
||||
|
||||
def create_empty_image_block(self, document_id: str, parent_id: str = None) -> str:
|
||||
"""
|
||||
Step 1: 创建空图片块
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
parent_id: 父块ID (默认为文档根块)
|
||||
|
||||
Returns:
|
||||
block_id: 图片块ID
|
||||
"""
|
||||
parent_id = parent_id or document_id
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params={"document_revision_id": -1},
|
||||
json=payload
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
|
||||
"""
|
||||
Step 2: 上传图片文件
|
||||
|
||||
Args:
|
||||
file_path: 本地图片路径
|
||||
block_id: 图片块ID (从 Step 1 获取)
|
||||
|
||||
Returns:
|
||||
file_token: 图片token
|
||||
"""
|
||||
url = f"{self.BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# 根据文件扩展名确定 MIME 类型
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
mime_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
}
|
||||
mime_type = mime_types.get(ext, 'image/png')
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, mime_type)}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传图片失败: {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> bool:
|
||||
"""
|
||||
Step 3: 绑定图片到图片块 (关键步骤!)
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
block_id: 图片块ID
|
||||
file_token: 图片token (从 Step 2 获取)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.patch(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params={"document_revision_id": -1},
|
||||
json=payload
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定图片失败: {data}")
|
||||
|
||||
return True
|
||||
|
||||
def insert_image(self, document_id: str, file_path: str, parent_id: str = None) -> dict:
|
||||
"""
|
||||
插入图片到文档 (封装三步流程)
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
file_path: 本地图片路径
|
||||
parent_id: 父块ID (默认为文档根块)
|
||||
|
||||
Returns:
|
||||
dict: {"block_id": "xxx", "file_token": "xxx"}
|
||||
"""
|
||||
# Step 1: 创建空图片块
|
||||
block_id = self.create_empty_image_block(document_id, parent_id)
|
||||
|
||||
# Step 2: 上传图片
|
||||
file_token = self.upload_image_to_block(file_path, block_id)
|
||||
|
||||
# Step 3: 绑定图片
|
||||
self.bind_image_to_block(document_id, block_id, file_token)
|
||||
|
||||
return {"block_id": block_id, "file_token": file_token}
|
||||
|
||||
def check_image_status(self, document_id: str) -> List[Dict]:
|
||||
"""
|
||||
检查文档中图片块的状态
|
||||
|
||||
Returns:
|
||||
List[Dict]: 图片块状态列表
|
||||
"""
|
||||
blocks = self.get_document_blocks(document_id)
|
||||
images = []
|
||||
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_data = block.get("image", {})
|
||||
images.append({
|
||||
"block_id": block.get("block_id"),
|
||||
"token": image_data.get("token", ""),
|
||||
"width": image_data.get("width", 0),
|
||||
"height": image_data.get("height", 0),
|
||||
"is_valid": bool(image_data.get("token"))
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
# ==================== 内容块构建器 ====================
|
||||
|
||||
@staticmethod
|
||||
def heading1(text: str) -> dict:
|
||||
"""一级标题"""
|
||||
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
@staticmethod
|
||||
def heading2(text: str) -> dict:
|
||||
"""二级标题"""
|
||||
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
@staticmethod
|
||||
def heading3(text: str) -> dict:
|
||||
"""三级标题"""
|
||||
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
@staticmethod
|
||||
def text(content: str) -> dict:
|
||||
"""文本段落"""
|
||||
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
@staticmethod
|
||||
def bullet(content: str) -> dict:
|
||||
"""无序列表项"""
|
||||
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
@staticmethod
|
||||
def ordered(content: str) -> dict:
|
||||
"""有序列表项"""
|
||||
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
@staticmethod
|
||||
def code(content: str, language: int = 1) -> dict:
|
||||
"""代码块 (language: 1=JSON, 4=Python, 等)"""
|
||||
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
|
||||
|
||||
@staticmethod
|
||||
def divider() -> dict:
|
||||
"""分割线"""
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
@staticmethod
|
||||
def todo(content: str, done: bool = False) -> dict:
|
||||
"""待办事项"""
|
||||
return {"block_type": 17, "todo": {"elements": [{"text_run": {"content": content}}], "style": {"done": done}}}
|
||||
|
||||
# ==================== 高级功能 ====================
|
||||
|
||||
def create_meeting_minutes(self, title: str, content: dict) -> dict:
|
||||
"""
|
||||
创建会议纪要
|
||||
|
||||
Args:
|
||||
title: 文档标题
|
||||
content: {
|
||||
"summary": "会议摘要",
|
||||
"points": [{"title": "...", "items": ["..."]}],
|
||||
"todos": [{"assignee": "...", "task": "..."}]
|
||||
}
|
||||
|
||||
Returns:
|
||||
dict: 文档信息
|
||||
"""
|
||||
doc = self.create_document(title)
|
||||
document_id = doc["document_id"]
|
||||
|
||||
blocks = []
|
||||
|
||||
if content.get("summary"):
|
||||
blocks.append(self.heading2("会议摘要"))
|
||||
blocks.append(self.text(content["summary"]))
|
||||
blocks.append(self.divider())
|
||||
|
||||
if content.get("points"):
|
||||
blocks.append(self.heading2("小结"))
|
||||
for point in content["points"]:
|
||||
blocks.append(self.heading3(point["title"]))
|
||||
for item in point.get("items", []):
|
||||
blocks.append(self.bullet(item))
|
||||
blocks.append(self.divider())
|
||||
|
||||
if content.get("todos"):
|
||||
blocks.append(self.heading2("待办事项"))
|
||||
for todo_item in content["todos"]:
|
||||
text = f"{todo_item['assignee']}: {todo_item['task']}"
|
||||
blocks.append(self.todo(text))
|
||||
|
||||
self.create_blocks(document_id, blocks)
|
||||
return doc
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
if __name__ == "__main__":
|
||||
docx = FeishuDocx()
|
||||
|
||||
# 示例1: 创建文档并插入图片
|
||||
print("=== 示例1: 创建带图片的文档 ===")
|
||||
doc = docx.create_document("图片上传测试文档")
|
||||
print(f"文档创建成功: {doc['url']}")
|
||||
|
||||
# 如果有测试图片,取消注释以下代码
|
||||
# result = docx.insert_image(doc["document_id"], "/path/to/image.png")
|
||||
# print(f"图片插入成功: {result}")
|
||||
|
||||
# 示例2: 创建会议纪要
|
||||
print("\n=== 示例2: 创建会议纪要 ===")
|
||||
doc = docx.create_meeting_minutes(
|
||||
"测试会议纪要",
|
||||
{
|
||||
"summary": "本次会议讨论了项目进度...",
|
||||
"points": [{"title": "进度汇报", "items": ["任务A已完成", "任务B进行中"]}],
|
||||
"todos": [{"assignee": "张三", "task": "完成文档编写"}]
|
||||
}
|
||||
)
|
||||
print(f"会议纪要已创建: {doc['url']}")
|
||||
|
||||
# 示例3: 检查图片状态
|
||||
print("\n=== 示例3: 检查图片状态 ===")
|
||||
images = docx.check_image_status(doc["document_id"])
|
||||
if images:
|
||||
for img in images:
|
||||
status = "✅ 有效" if img["is_valid"] else "❌ 空"
|
||||
print(f"图片: {status}, 尺寸: {img['width']}x{img['height']}")
|
||||
else:
|
||||
print("文档中没有图片")
|
||||
308
plugins/feishu-plugin/migrate_wps.py
Normal file
308
plugins/feishu-plugin/migrate_wps.py
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WPS .dbt 文件迁移到飞书多维表格
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 源文件
|
||||
SOURCE_FILE = "/Users/donglinlai/Downloads/酷采团购系统优化进度表.dbt.xlsx"
|
||||
|
||||
|
||||
class FeishuBitable:
|
||||
"""飞书多维表格操作工具类"""
|
||||
|
||||
def __init__(self):
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def create_bitable(self, name: str) -> Dict:
|
||||
"""创建多维表格"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps"
|
||||
response = requests.post(url, headers=self.headers, json={"name": name})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
return data["data"]["app"]
|
||||
|
||||
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
|
||||
"""创建数据表"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||
payload = {
|
||||
"table": {
|
||||
"name": name,
|
||||
"default_view_name": "默认视图",
|
||||
"fields": fields
|
||||
}
|
||||
}
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建数据表失败: {data}")
|
||||
|
||||
return data["data"]
|
||||
|
||||
def batch_create_records(self, app_token: str, table_id: str,
|
||||
records: List[Dict], batch_size: int = 100) -> int:
|
||||
"""批量创建记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
||||
total_created = 0
|
||||
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = records[i:i+batch_size]
|
||||
payload = {"records": [{"fields": r} for r in batch]}
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
print(f" [WARN] 批次 {i//batch_size + 1} 部分失败: {data.get('msg', '')}")
|
||||
# 尝试逐条插入
|
||||
for record in batch:
|
||||
try:
|
||||
single_url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
single_resp = requests.post(single_url, headers=self.headers, json={"fields": record})
|
||||
if single_resp.json().get("code") == 0:
|
||||
total_created += 1
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
total_created += len(data["data"]["records"])
|
||||
|
||||
return total_created
|
||||
|
||||
|
||||
def analyze_column_type(series: pd.Series, col_name: str) -> Dict:
|
||||
"""分析列的数据类型,返回飞书字段定义"""
|
||||
col_lower = col_name.lower()
|
||||
|
||||
# 根据列名判断类型
|
||||
if any(kw in col_lower for kw in ['日期', '时间', 'date', 'time', '提出时间', '发版日期', '更新时间']):
|
||||
return {"field_name": col_name, "type": 5, "property": {"date_formatter": "yyyy/MM/dd"}}
|
||||
|
||||
if any(kw in col_lower for kw in ['图片', '附件', '截图', 'image', 'file', 'attachment']):
|
||||
return {"field_name": col_name, "type": 1} # 作为文本处理
|
||||
|
||||
if any(kw in col_lower for kw in ['优先级', '状态', '类型', '分类', '终端', '严重程度']):
|
||||
# 提取唯一值作为选项
|
||||
unique_vals = series.dropna().astype(str).unique()
|
||||
unique_vals = [v for v in unique_vals if v and v != 'nan' and len(v) < 50][:20]
|
||||
if len(unique_vals) > 0 and len(unique_vals) <= 20:
|
||||
return {
|
||||
"field_name": col_name,
|
||||
"type": 3, # 单选
|
||||
"property": {
|
||||
"options": [{"name": str(v)} for v in unique_vals]
|
||||
}
|
||||
}
|
||||
|
||||
if any(kw in col_lower for kw in ['进度', '百分比', '%']):
|
||||
return {"field_name": col_name, "type": 2} # 数字
|
||||
|
||||
# 检查是否为数字列
|
||||
try:
|
||||
numeric_vals = pd.to_numeric(series.dropna(), errors='coerce')
|
||||
if numeric_vals.notna().sum() / max(len(series.dropna()), 1) > 0.8:
|
||||
return {"field_name": col_name, "type": 2} # 数字
|
||||
except:
|
||||
pass
|
||||
|
||||
# 默认为文本
|
||||
return {"field_name": col_name, "type": 1}
|
||||
|
||||
|
||||
def clean_value(val: Any, field_type: int) -> Any:
|
||||
"""清理和转换值"""
|
||||
if pd.isna(val) or val is None:
|
||||
return None
|
||||
|
||||
if field_type == 5: # 日期
|
||||
try:
|
||||
if isinstance(val, (datetime, pd.Timestamp)):
|
||||
return int(val.timestamp() * 1000)
|
||||
elif isinstance(val, str):
|
||||
dt = pd.to_datetime(val)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except:
|
||||
return None
|
||||
|
||||
if field_type == 2: # 数字
|
||||
try:
|
||||
return float(val)
|
||||
except:
|
||||
return None
|
||||
|
||||
if field_type == 3: # 单选
|
||||
val_str = str(val).strip()
|
||||
if val_str and val_str != 'nan':
|
||||
return val_str
|
||||
return None
|
||||
|
||||
# 文本类型
|
||||
val_str = str(val).strip()
|
||||
if val_str == 'nan' or not val_str:
|
||||
return None
|
||||
|
||||
# 限制文本长度
|
||||
if len(val_str) > 10000:
|
||||
val_str = val_str[:10000] + "..."
|
||||
|
||||
return val_str
|
||||
|
||||
|
||||
def migrate_sheet(bitable: FeishuBitable, app_token: str,
|
||||
df: pd.DataFrame, sheet_name: str) -> str:
|
||||
"""迁移单个 Sheet 到数据表"""
|
||||
print(f"\n{'='*50}")
|
||||
print(f"迁移 Sheet: 【{sheet_name}】")
|
||||
print(f"{'='*50}")
|
||||
|
||||
# 清理列名
|
||||
df.columns = [str(c).strip() for c in df.columns]
|
||||
|
||||
# 去除完全空的行
|
||||
df = df.dropna(how='all')
|
||||
|
||||
print(f" 数据: {len(df)} 行, {len(df.columns)} 列")
|
||||
|
||||
# 分析字段类型
|
||||
fields = []
|
||||
field_types = {}
|
||||
for col in df.columns:
|
||||
if not col or col.startswith('Unnamed'):
|
||||
continue
|
||||
field_def = analyze_column_type(df[col], col)
|
||||
fields.append(field_def)
|
||||
field_types[col] = field_def["type"]
|
||||
|
||||
print(f" 字段: {len(fields)} 个")
|
||||
|
||||
# 创建数据表
|
||||
table_info = bitable.create_table(app_token, sheet_name, fields)
|
||||
table_id = table_info["table_id"]
|
||||
print(f" [OK] 数据表创建成功: {table_id}")
|
||||
|
||||
# 准备记录数据
|
||||
records = []
|
||||
for _, row in df.iterrows():
|
||||
record = {}
|
||||
for col in df.columns:
|
||||
if not col or col.startswith('Unnamed'):
|
||||
continue
|
||||
val = clean_value(row[col], field_types.get(col, 1))
|
||||
if val is not None:
|
||||
record[col] = val
|
||||
if record: # 只添加非空记录
|
||||
records.append(record)
|
||||
|
||||
print(f" 准备导入 {len(records)} 条记录...")
|
||||
|
||||
# 批量创建记录
|
||||
if records:
|
||||
created = bitable.batch_create_records(app_token, table_id, records)
|
||||
print(f" [OK] 成功导入 {created}/{len(records)} 条记录")
|
||||
else:
|
||||
print(f" [INFO] 无有效数据")
|
||||
|
||||
return table_id
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# WPS 文件迁移到飞书多维表格")
|
||||
print("#" * 60)
|
||||
|
||||
bitable = FeishuBitable()
|
||||
|
||||
# Step 1: 读取源文件
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 1: 读取源文件")
|
||||
print("=" * 50)
|
||||
|
||||
xlsx = pd.ExcelFile(SOURCE_FILE)
|
||||
print(f" 文件: {SOURCE_FILE}")
|
||||
print(f" Sheet 数量: {len(xlsx.sheet_names)}")
|
||||
|
||||
# Step 2: 创建多维表格
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 2: 创建飞书多维表格")
|
||||
print("=" * 50)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
bitable_name = f"酷采团购系统优化进度表 (迁移 {timestamp})"
|
||||
|
||||
app_info = bitable.create_bitable(bitable_name)
|
||||
app_token = app_info["app_token"]
|
||||
print(f" [OK] 多维表格创建成功")
|
||||
print(f" 名称: {bitable_name}")
|
||||
print(f" app_token: {app_token}")
|
||||
|
||||
# Step 3: 迁移每个 Sheet
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 3: 迁移数据表")
|
||||
print("=" * 50)
|
||||
|
||||
table_ids = {}
|
||||
for sheet_name in xlsx.sheet_names:
|
||||
df = pd.read_excel(xlsx, sheet_name=sheet_name)
|
||||
table_id = migrate_sheet(bitable, app_token, df, sheet_name)
|
||||
table_ids[sheet_name] = table_id
|
||||
|
||||
# 完成
|
||||
print("\n" + "=" * 60)
|
||||
print("迁移完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n多维表格信息:")
|
||||
print(f" 名称: {bitable_name}")
|
||||
print(f" app_token: {app_token}")
|
||||
print(f"\n数据表:")
|
||||
for name, tid in table_ids.items():
|
||||
print(f" - {name}: {tid}")
|
||||
print(f"\n访问地址:")
|
||||
print(f" https://zhiyuncai.feishu.cn/base/{app_token}")
|
||||
print()
|
||||
|
||||
return app_token, table_ids
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
449
plugins/feishu-plugin/rebuild_visibility_manual.py
Normal file
449
plugins/feishu-plugin/rebuild_visibility_manual.py
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
重建飞书文档:ai-proj 项目可见性手册
|
||||
清理现有内容后重新生成
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 目标文档
|
||||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def delete_block(document_id: str, block_id: str):
|
||||
"""删除块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||||
result = response.json()
|
||||
return result.get("code") == 0
|
||||
|
||||
|
||||
def clear_document(document_id: str):
|
||||
"""清空文档内容(保留根块)"""
|
||||
print("正在清空文档...")
|
||||
blocks = get_document_blocks(document_id)
|
||||
|
||||
# 找出所有子块(排除根块本身)
|
||||
child_blocks = [b for b in blocks if b.get("block_id") != document_id and b.get("parent_id") == document_id]
|
||||
|
||||
print(f" 发现 {len(child_blocks)} 个顶级子块需要删除")
|
||||
|
||||
# 从后向前删除,避免索引问题
|
||||
for block in reversed(child_blocks):
|
||||
block_id = block.get("block_id")
|
||||
if delete_block(document_id, block_id):
|
||||
print(f" 删除: {block_id[:20]}...")
|
||||
else:
|
||||
print(f" 删除失败: {block_id[:20]}...")
|
||||
time.sleep(0.1) # 避免请求过快
|
||||
|
||||
print(" 文档清空完成")
|
||||
|
||||
|
||||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||
"""创建内容块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {"children": blocks}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建块失败: {data}")
|
||||
return data["data"].get("children", [])
|
||||
|
||||
|
||||
def create_image_block(document_id: str, parent_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {"replace_image": {"token": file_token}}
|
||||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||||
"""插入图片的完整流程"""
|
||||
print(f" 插入图片: {description}")
|
||||
|
||||
# Step 1: 创建空图片块
|
||||
block_id = create_image_block(document_id, document_id)
|
||||
print(f" block_id: {block_id[:20]}...")
|
||||
|
||||
# Step 2: 上传图片
|
||||
file_token = upload_image(file_path, block_id)
|
||||
print(f" file_token: {file_token[:20]}...")
|
||||
|
||||
# Step 3: 绑定图片
|
||||
bind_image(document_id, block_id, file_token)
|
||||
print(f" 绑定成功!")
|
||||
|
||||
return block_id
|
||||
|
||||
|
||||
def generate_visibility_diagram():
|
||||
"""生成可见性示意图"""
|
||||
output_path = "/tmp/visibility_diagram.png"
|
||||
|
||||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 尝试加载字体,如果失败则使用默认
|
||||
try:
|
||||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||||
except:
|
||||
font_large = ImageFont.load_default()
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题背景
|
||||
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||||
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||||
|
||||
# 私有项目区域
|
||||
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||||
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||||
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||||
|
||||
# 私有项目说明
|
||||
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 私有项目图标
|
||||
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||||
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||||
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||||
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目区域
|
||||
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||||
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||||
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目说明
|
||||
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目图标组
|
||||
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||||
for x, y in positions:
|
||||
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||||
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||||
|
||||
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||||
|
||||
# 中间箭头
|
||||
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_ui_screenshot():
|
||||
"""生成 UI 示意图"""
|
||||
output_path = "/tmp/visibility_ui.png"
|
||||
|
||||
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||||
except:
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题
|
||||
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||||
|
||||
# 私有项目选项(选中状态)
|
||||
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||||
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||||
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||||
|
||||
# 默认标签
|
||||
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||||
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目选项(未选中状态)
|
||||
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||||
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||||
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||||
|
||||
# 底部说明
|
||||
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||||
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||||
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# 文档内容块定义
|
||||
def heading1(text):
|
||||
return {
|
||||
"block_type": 3,
|
||||
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading2(text):
|
||||
return {
|
||||
"block_type": 4,
|
||||
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading3(text):
|
||||
return {
|
||||
"block_type": 5,
|
||||
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def text_block(content):
|
||||
return {
|
||||
"block_type": 2,
|
||||
"text": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def bullet(content):
|
||||
return {
|
||||
"block_type": 12,
|
||||
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def ordered(content):
|
||||
return {
|
||||
"block_type": 13,
|
||||
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def code_block(content, language=1):
|
||||
return {
|
||||
"block_type": 14,
|
||||
"code": {
|
||||
"elements": [{"text_run": {"content": content}}],
|
||||
"language": language
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def divider():
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 重建飞书文档:ai-proj 项目可见性手册")
|
||||
print("#" * 60)
|
||||
|
||||
# Step 1: 生成示意图
|
||||
print("\n--- Step 1: 生成示意图 ---")
|
||||
diagram_path = generate_visibility_diagram()
|
||||
ui_path = generate_ui_screenshot()
|
||||
|
||||
# Step 2: 清空文档
|
||||
print("\n--- Step 2: 清空文档 ---")
|
||||
clear_document(DOCUMENT_ID)
|
||||
time.sleep(1) # 等待清空完成
|
||||
|
||||
# Step 3: 创建手册内容
|
||||
print("\n--- Step 3: 创建手册内容 ---")
|
||||
|
||||
# 第一部分:标题和概述
|
||||
blocks_part1 = [
|
||||
heading1("ai-proj 项目可见性手册"),
|
||||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||
divider(),
|
||||
heading2("1. 功能概述"),
|
||||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||
divider(),
|
||||
heading2("2. 可见性对比"),
|
||||
text_block("下图展示了两种可见性的区别:"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part1)
|
||||
print(" 第一部分内容创建完成")
|
||||
|
||||
# 插入可见性示意图
|
||||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||
insert_image(DOCUMENT_ID, diagram_path, "可见性示意图")
|
||||
|
||||
# 第二部分:设置方法
|
||||
print("\n--- Step 5: 添加设置说明 ---")
|
||||
blocks_part2 = [
|
||||
divider(),
|
||||
heading2("3. 设置项目可见性"),
|
||||
heading3("3.1 创建项目时设置"),
|
||||
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||||
ordered("点击「创建项目」按钮"),
|
||||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||
ordered("选择「私有项目」或「企业项目」"),
|
||||
ordered("填写其他信息后点击「确定」"),
|
||||
text_block("UI 界面示意:"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part2)
|
||||
print(" 设置说明创建完成")
|
||||
|
||||
# 插入 UI 示意图
|
||||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||
insert_image(DOCUMENT_ID, ui_path, "UI 示意图")
|
||||
|
||||
# 第三部分:修改方法和规则
|
||||
print("\n--- Step 7: 添加剩余内容 ---")
|
||||
blocks_part3 = [
|
||||
heading3("3.2 修改已有项目"),
|
||||
ordered("在项目列表中找到目标项目"),
|
||||
ordered("点击项目卡片上的「编辑」按钮"),
|
||||
ordered("在编辑页面修改「项目可见性」"),
|
||||
ordered("点击「保存」"),
|
||||
divider(),
|
||||
heading2("4. 可见性规则"),
|
||||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||
divider(),
|
||||
heading2("5. API 参考"),
|
||||
text_block("修改项目可见性的 API:"),
|
||||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||
|
||||
请求体:
|
||||
{
|
||||
"visibility": "enterprise"
|
||||
}
|
||||
|
||||
可选值: "private" | "enterprise"'''),
|
||||
divider(),
|
||||
heading2("6. 注意事项"),
|
||||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||
divider(),
|
||||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part3)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("文档重建完成!")
|
||||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
# 验证图片
|
||||
print("\n--- 验证图片状态 ---")
|
||||
blocks = get_document_blocks(DOCUMENT_ID)
|
||||
image_count = 0
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_count += 1
|
||||
image_data = block.get("image", {})
|
||||
token = image_data.get("token", "")
|
||||
print(f"图片 #{image_count}: token={'有效' if token else '空'} ({token[:20] if token else 'N/A'}...)")
|
||||
|
||||
if image_count == 0:
|
||||
print("警告:文档中没有图片块")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
530
plugins/feishu-plugin/scripts/aiproj_sync.py
Normal file
530
plugins/feishu-plugin/scripts/aiproj_sync.py
Normal file
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ai-proj 与飞书项目同步脚本
|
||||
|
||||
功能:
|
||||
- 初始化:创建飞书根目录、多维表格
|
||||
- 全量同步:同步所有项目到飞书
|
||||
- 增量同步:只同步新增项目
|
||||
- 更新同步:更新现有项目的任务统计等信息
|
||||
|
||||
使用方法:
|
||||
python aiproj_sync.py init # 首次初始化
|
||||
python aiproj_sync.py sync # 增量同步(推荐日常使用)
|
||||
python aiproj_sync.py sync-all # 全量同步
|
||||
python aiproj_sync.py update # 更新统计信息
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
|
||||
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
|
||||
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
|
||||
|
||||
# 默认协作者 open_id(邱栋梁@智云采购)
|
||||
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
|
||||
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
|
||||
|
||||
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
|
||||
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
|
||||
|
||||
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
|
||||
|
||||
# ============================================
|
||||
# 飞书 API
|
||||
# ============================================
|
||||
|
||||
class FeishuAPI:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""获取 tenant_access_token"""
|
||||
if self.token:
|
||||
return self.token
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": FEISHU_APP_ID,
|
||||
"app_secret": FEISHU_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
self.token = data["tenant_access_token"]
|
||||
return self.token
|
||||
else:
|
||||
raise Exception(f"获取飞书 token 失败: {data}")
|
||||
|
||||
def _headers(self, content_type: bool = False) -> Dict:
|
||||
headers = {"Authorization": f"Bearer {self.get_token()}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def get_root_folder(self) -> str:
|
||||
"""获取云空间根文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["token"]
|
||||
raise Exception(f"获取根文件夹失败: {data}")
|
||||
|
||||
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
|
||||
"""创建文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": parent_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
folder_token = data["data"]["token"]
|
||||
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
|
||||
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
|
||||
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
|
||||
return folder_token
|
||||
raise Exception(f"创建文件夹失败: {data}")
|
||||
|
||||
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
|
||||
"""添加协作者
|
||||
|
||||
应用通过 API 创建的文件/文件夹,所有者是应用本身。
|
||||
组织内用户默认无法访问,需要显式添加为协作者。
|
||||
|
||||
Args:
|
||||
file_token: 文件/文件夹 token
|
||||
file_type: 类型 (folder, doc, sheet, bitable 等)
|
||||
user_open_id: 用户 open_id
|
||||
perm: 权限级别 (full_access, edit, view)
|
||||
"""
|
||||
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
|
||||
params = {"type": file_type, "need_notification": "false"}
|
||||
payload = {
|
||||
"member_type": "openid",
|
||||
"member_id": user_open_id,
|
||||
"perm": perm
|
||||
}
|
||||
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def create_bitable(self, name: str, folder_token: str) -> Dict:
|
||||
"""创建多维表格"""
|
||||
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": folder_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["app"]
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
def get_bitable_tables(self, app_token: str) -> List[Dict]:
|
||||
"""获取数据表列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
raise Exception(f"获取数据表失败: {data}")
|
||||
|
||||
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
|
||||
"""创建字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.post(url, headers=self._headers(True), json=field)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["field"]
|
||||
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
|
||||
"""更新字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json=updates)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||
"""获取字段列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
return []
|
||||
|
||||
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
|
||||
"""添加记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["record"]
|
||||
print(f" 记录添加失败: {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
|
||||
"""获取记录列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": 500}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self._headers(), params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"].get("items", [])
|
||||
return []
|
||||
|
||||
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
|
||||
"""更新记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
|
||||
return response.json().get("code") == 0
|
||||
|
||||
# ============================================
|
||||
# ai-proj API
|
||||
# ============================================
|
||||
|
||||
class AIProjAPI:
|
||||
@staticmethod
|
||||
def get_projects() -> List[Dict]:
|
||||
"""获取项目列表"""
|
||||
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
|
||||
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("success"):
|
||||
return data["data"]["data"]
|
||||
raise Exception(f"获取项目列表失败: {data}")
|
||||
|
||||
# ============================================
|
||||
# 配置管理
|
||||
# ============================================
|
||||
|
||||
def load_config() -> Optional[Dict]:
|
||||
"""加载配置"""
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
def save_config(config: Dict):
|
||||
"""保存配置"""
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# ============================================
|
||||
# 同步逻辑
|
||||
# ============================================
|
||||
|
||||
def init():
|
||||
"""初始化:创建飞书目录结构"""
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 初始化")
|
||||
print("=" * 50)
|
||||
|
||||
if load_config():
|
||||
print("\n已存在配置文件,是否重新初始化?(y/N)")
|
||||
if input().lower() != 'y':
|
||||
print("取消初始化")
|
||||
return
|
||||
|
||||
feishu = FeishuAPI()
|
||||
|
||||
# 1. 获取根目录
|
||||
print("\n[1/5] 获取云空间根目录...")
|
||||
space_root = feishu.get_root_folder()
|
||||
print(f" 根目录: {space_root}")
|
||||
|
||||
# 2. 创建 ai-proj 文件夹
|
||||
print("\n[2/5] 创建 ai-proj 根文件夹...")
|
||||
root_folder = feishu.create_folder("ai-proj", space_root)
|
||||
print(f" 创建成功: {root_folder}")
|
||||
|
||||
# 3. 创建多维表格
|
||||
print("\n[3/5] 创建项目目录多维表格...")
|
||||
bitable = feishu.create_bitable("项目目录", root_folder)
|
||||
app_token = bitable["app_token"]
|
||||
print(f" 创建成功: {app_token}")
|
||||
|
||||
tables = feishu.get_bitable_tables(app_token)
|
||||
table_id = tables[0]["table_id"]
|
||||
|
||||
# 4. 创建字段
|
||||
print("\n[4/5] 创建数据表字段...")
|
||||
|
||||
# 先修改第一列名称
|
||||
fields = feishu.get_fields(app_token, table_id)
|
||||
if fields:
|
||||
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
|
||||
"field_name": "项目名称",
|
||||
"type": fields[0]["type"]
|
||||
})
|
||||
print(" 修改字段: 项目名称")
|
||||
|
||||
# 创建其他字段
|
||||
field_definitions = [
|
||||
{"field_name": "项目ID", "type": 2},
|
||||
{"field_name": "项目编号", "type": 1},
|
||||
{"field_name": "飞书文件夹", "type": 15},
|
||||
{"field_name": "状态", "type": 3, "property": {"options": [
|
||||
{"name": "active", "color": 0},
|
||||
{"name": "on_hold", "color": 1},
|
||||
{"name": "planning", "color": 2},
|
||||
{"name": "completed", "color": 3},
|
||||
{"name": "archived", "color": 4}
|
||||
]}},
|
||||
{"field_name": "优先级", "type": 3, "property": {"options": [
|
||||
{"name": "high", "color": 0},
|
||||
{"name": "medium", "color": 1},
|
||||
{"name": "low", "color": 2}
|
||||
]}},
|
||||
{"field_name": "所属企业", "type": 1},
|
||||
{"field_name": "任务总数", "type": 2},
|
||||
{"field_name": "描述", "type": 1},
|
||||
{"field_name": "创建时间", "type": 5},
|
||||
{"field_name": "最后同步", "type": 5},
|
||||
]
|
||||
|
||||
for field in field_definitions:
|
||||
if feishu.create_field(app_token, table_id, field):
|
||||
print(f" 创建字段: {field['field_name']}")
|
||||
|
||||
# 5. 保存配置
|
||||
print("\n[5/5] 保存配置...")
|
||||
config = {
|
||||
"root_folder_token": root_folder,
|
||||
"bitable_app_token": app_token,
|
||||
"bitable_table_id": table_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"synced_projects": {}
|
||||
}
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("初始化完成!")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
|
||||
print(f"多维表格: https://feishu.cn/base/{app_token}")
|
||||
print(f"\n配置文件: {CONFIG_PATH}")
|
||||
print("\n运行 'python aiproj_sync.py sync' 同步项目")
|
||||
|
||||
def sync(full: bool = False):
|
||||
"""同步项目"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
root_folder = config["root_folder_token"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取 ai-proj 项目
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
print(f" 共 {len(projects)} 个项目")
|
||||
|
||||
# 筛选需要同步的项目
|
||||
if full:
|
||||
to_sync = projects
|
||||
else:
|
||||
to_sync = [p for p in projects if str(p["id"]) not in synced]
|
||||
|
||||
if not to_sync:
|
||||
print("\n没有新项目需要同步")
|
||||
return
|
||||
|
||||
print(f"\n需要同步 {len(to_sync)} 个项目...")
|
||||
|
||||
success = 0
|
||||
for project in to_sync:
|
||||
project_id = str(project["id"])
|
||||
project_name = project["name"]
|
||||
folder_name = f"{project_name}_{project_id}"
|
||||
|
||||
print(f"\n 处理: {project_name} (ID: {project_id})")
|
||||
|
||||
# 创建文件夹(如果不存在)
|
||||
folder_token = synced.get(project_id, {}).get("folder_token")
|
||||
if not folder_token:
|
||||
try:
|
||||
folder_token = feishu.create_folder(folder_name, root_folder)
|
||||
print(f" 创建文件夹: {folder_token}")
|
||||
except Exception as e:
|
||||
print(f" 文件夹创建失败: {e}")
|
||||
folder_token = ""
|
||||
|
||||
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
|
||||
|
||||
# 添加/更新记录
|
||||
record_fields = {
|
||||
"项目名称": project_name,
|
||||
"项目ID": int(project_id),
|
||||
"项目编号": project.get("project_number", ""),
|
||||
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
|
||||
"状态": project.get("status", "active"),
|
||||
"优先级": project.get("priority", "medium"),
|
||||
"所属企业": project.get("company_name", ""),
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"描述": (project.get("description", "") or "")[:500],
|
||||
"创建时间": int(datetime.fromisoformat(
|
||||
project["created_at"].replace("Z", "+00:00")
|
||||
).timestamp() * 1000),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
record_id = synced.get(project_id, {}).get("record_id")
|
||||
if record_id and not full:
|
||||
# 更新现有记录
|
||||
if feishu.update_record(app_token, table_id, record_id, record_fields):
|
||||
print(f" 更新记录成功")
|
||||
success += 1
|
||||
else:
|
||||
# 添加新记录
|
||||
record = feishu.add_record(app_token, table_id, record_fields)
|
||||
if record:
|
||||
record_id = record["record_id"]
|
||||
print(f" 添加记录成功")
|
||||
success += 1
|
||||
|
||||
# 更新同步记录
|
||||
synced[project_id] = {
|
||||
"folder_token": folder_token,
|
||||
"folder_url": folder_url,
|
||||
"record_id": record_id,
|
||||
"synced_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 保存配置
|
||||
config["synced_projects"] = synced
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"同步完成: {success}/{len(to_sync)}")
|
||||
print("=" * 50)
|
||||
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
|
||||
|
||||
def update_stats():
|
||||
"""更新任务统计信息"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 更新统计")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取最新项目数据
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
project_map = {str(p["id"]): p for p in projects}
|
||||
|
||||
updated = 0
|
||||
for project_id, sync_info in synced.items():
|
||||
record_id = sync_info.get("record_id")
|
||||
if not record_id:
|
||||
continue
|
||||
|
||||
project = project_map.get(project_id)
|
||||
if not project:
|
||||
continue
|
||||
|
||||
# 只更新统计字段
|
||||
update_fields = {
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"状态": project.get("status", "active"),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
if feishu.update_record(app_token, table_id, record_id, update_fields):
|
||||
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
|
||||
updated += 1
|
||||
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print(f"\n更新完成: {updated} 个项目")
|
||||
|
||||
def show_status():
|
||||
"""显示同步状态"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未初始化,请先运行: python aiproj_sync.py init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("ai-proj 飞书同步状态")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
|
||||
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
|
||||
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
|
||||
print(f"上次同步: {config.get('last_sync', '从未')}")
|
||||
print(f"配置文件: {CONFIG_PATH}")
|
||||
|
||||
# ============================================
|
||||
# 主入口
|
||||
# ============================================
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python aiproj_sync.py <command>")
|
||||
print("\n命令:")
|
||||
print(" init 首次初始化(创建目录和多维表格)")
|
||||
print(" sync 增量同步(只同步新项目)")
|
||||
print(" sync-all 全量同步(同步所有项目)")
|
||||
print(" update 更新统计信息")
|
||||
print(" status 查看同步状态")
|
||||
return
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "init":
|
||||
init()
|
||||
elif command == "sync":
|
||||
sync(full=False)
|
||||
elif command == "sync-all":
|
||||
sync(full=True)
|
||||
elif command == "update":
|
||||
update_stats()
|
||||
elif command == "status":
|
||||
show_status()
|
||||
else:
|
||||
print(f"未知命令: {command}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
139
plugins/feishu-plugin/skills/SKILL.md
Normal file
139
plugins/feishu-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: feishu
|
||||
description: 飞书文档与多维表格操作入口。当用户提到飞书、云文档、多维表格、Bitable 相关任务时自动激活。
|
||||
---
|
||||
|
||||
# 飞书集成
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 技能 | 说明 |
|
||||
|------|------|------|
|
||||
| 云文档 | `feishu-docx` | 创建、编辑云文档,会议纪要 |
|
||||
| 多维表格 | `feishu-bitable` | 记录增删改查,数据同步 |
|
||||
| 任务 | 本技能 | 创建待办任务 |
|
||||
|
||||
## 环境配置
|
||||
|
||||
```bash
|
||||
# ~/.zshrc(凭证唯一配置位置)
|
||||
export FEISHU_APP_ID="cli_a9f29dca82b9dbef"
|
||||
export FEISHU_APP_SECRET="<从飞书开放平台获取>"
|
||||
```
|
||||
|
||||
**权限要求**:
|
||||
- 云文档:`docx:document`, `drive:drive`
|
||||
- 多维表格:`bitable:app`
|
||||
- 任务:`task:task:write`
|
||||
|
||||
## Access Token
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
|
||||
def get_tenant_access_token():
|
||||
"""获取飞书 tenant_access_token"""
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": os.environ["FEISHU_APP_ID"],
|
||||
"app_secret": os.environ["FEISHU_APP_SECRET"]
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") == 0:
|
||||
return data["tenant_access_token"]
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
```
|
||||
|
||||
## 默认存储位置
|
||||
|
||||
| 文件夹 | folder_token |
|
||||
|--------|-------------|
|
||||
| ai-proj 根目录 | `RTLKf247ClQQDyd5IjxcTOVQnxd` |
|
||||
| 01运营 (默认) | `C80gfkRnzlonQ5d4AhOcOACDnNg` |
|
||||
|
||||
## URL 结构
|
||||
|
||||
```
|
||||
云文档: https://xxx.feishu.cn/docx/DoxcXXXXXX
|
||||
└── document_id
|
||||
|
||||
多维表格: https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
|
||||
└── app_token └── table_id
|
||||
```
|
||||
|
||||
## 飞书任务
|
||||
|
||||
```python
|
||||
def create_task(summary: str, due_time: int = None):
|
||||
"""创建飞书任务"""
|
||||
url = "https://open.feishu.cn/open-apis/task/v2/tasks"
|
||||
token = get_tenant_access_token()
|
||||
payload = {"summary": summary}
|
||||
if due_time:
|
||||
payload["due"] = {"timestamp": str(due_time), "is_all_day": False}
|
||||
response = requests.post(url,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["task"]
|
||||
raise Exception(f"创建任务失败: {data}")
|
||||
```
|
||||
|
||||
## 工具类
|
||||
|
||||
完整工具类见:
|
||||
- `~/.claude/skills/feishu/feishu_bitable.py` - 多维表格
|
||||
- `~/.claude/skills/feishu/feishu_docx.py` - 云文档
|
||||
|
||||
## Incoming Webhook(群机器人通知卡片)
|
||||
|
||||
**Webhook 地址**:存储在 `~/.config/devops/credentials.env` → `FEISHU_DEPLOY_WEBHOOK`
|
||||
|
||||
**⚠️ 关键注意事项**:
|
||||
- ❌ **禁用 schema 2.0**:`"schema": "2.0"` 会返回 ErrCode 11246,必须用 legacy 格式
|
||||
- ✅ **legacy 卡片格式**(无 schema 字段)才能正常发送
|
||||
- ❌ **按钮 URL 禁止指向列表页**:必须带具体资源 ID(如 `/requirements/864`,不能是 `/requirements`)
|
||||
- ✅ **保存到文件再 curl**:包含中文的 JSON 直接用 `'...'` 传参会报 "blank argument" 错误
|
||||
|
||||
**正确的卡片发送示例**:
|
||||
```bash
|
||||
cat > /tmp/feishu_card.json << 'EOF'
|
||||
{
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "通知标题"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": "**内容**:描述文字"}
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看详情"},
|
||||
"type": "primary",
|
||||
"url": "https://ai.pipexerp.com/requirements/864"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
curl -s -X POST \
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/feishu_card.json
|
||||
```
|
||||
|
||||
**header template 颜色**:`blue`(待审批)/ `green`(通过)/ `red`(驳回)/ `wathet`(信息)
|
||||
|
||||
## 相关技能
|
||||
|
||||
- `feishu-docx` - 云文档详细操作
|
||||
- `feishu-bitable` - 多维表格详细操作
|
||||
391
plugins/feishu-plugin/test_docx_image.py
Normal file
391
plugins/feishu-plugin/test_docx_image.py
Normal file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试飞书云文档图片上传
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
|
||||
class FeishuDocx:
|
||||
"""飞书云文档操作工具类"""
|
||||
|
||||
def __init__(self):
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
print(f"[OK] Token 获取成功")
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
|
||||
"""设置文档权限为组织内可编辑"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
|
||||
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
print(f"[OK] 权限设置成功: 组织内可编辑")
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] 权限设置失败: {result.get('msg')}")
|
||||
return False
|
||||
|
||||
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
|
||||
"""创建云文档(自动设置为组织内可编辑)"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
payload = {"title": title}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
|
||||
doc = data["data"]["document"]
|
||||
print(f"[OK] 文档创建成功: {doc['document_id']}")
|
||||
|
||||
# 自动设置权限
|
||||
if editable:
|
||||
self.set_document_permission(doc['document_id'], editable=True)
|
||||
|
||||
return doc
|
||||
|
||||
def create_empty_image_block(self, document_id: str, index: int = -1) -> str:
|
||||
"""
|
||||
创建空的图片块,返回 block_id
|
||||
|
||||
正确流程第一步:先创建空图片块
|
||||
"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||
|
||||
# 创建空的图片块
|
||||
image_block = {
|
||||
"block_type": 27,
|
||||
"image": {} # 空的图片块
|
||||
}
|
||||
|
||||
payload = {"children": [image_block]}
|
||||
if index >= 0:
|
||||
payload["index"] = index
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
|
||||
# 获取创建的图片块 ID
|
||||
children = data["data"].get("children", [])
|
||||
if not children:
|
||||
raise Exception("创建图片块失败: 没有返回 children")
|
||||
|
||||
block_id = children[0].get("block_id")
|
||||
print(f"[OK] 空图片块创建成功, block_id: {block_id}")
|
||||
return block_id
|
||||
|
||||
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
|
||||
"""
|
||||
上传图片到指定的图片块
|
||||
|
||||
正确流程第二步:将图片上传并绑定到已创建的图片块
|
||||
"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image', # 云文档图片
|
||||
'parent_node': block_id, # 关键: 使用图片块的 block_id
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
print(f"[INFO] 上传图片到 block_id={block_id}")
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 上传响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传图片失败: {result}")
|
||||
|
||||
file_token = result["data"]["file_token"]
|
||||
print(f"[OK] 图片上传成功, file_token: {file_token}")
|
||||
return file_token
|
||||
|
||||
def _upload_media(self, file_path: str, parent_type: str, parent_node: str = '') -> str:
|
||||
"""使用 media API 上传"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': parent_type,
|
||||
'parent_node': parent_node,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] media 响应 ({parent_type}): code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
return result["data"]["file_token"]
|
||||
return None
|
||||
|
||||
def _upload_to_drive(self, file_path: str) -> str:
|
||||
"""上传到云空间根目录,然后获取 file_token"""
|
||||
# 先获取根文件夹 token
|
||||
url = f"{BASE_URL}/drive/explorer/v2/root_folder/meta"
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 根目录响应: {result}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
return None
|
||||
|
||||
root_token = result["data"]["token"]
|
||||
|
||||
# 上传文件
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
upload_url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'explorer',
|
||||
'parent_node': root_token,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(upload_url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] drive 上传响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
return result["data"]["file_token"]
|
||||
return None
|
||||
|
||||
def _upload_im_image(self, file_path: str) -> str:
|
||||
"""使用消息图片 API 上传"""
|
||||
url = f"{BASE_URL}/im/v1/images"
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'image': f}
|
||||
data = {'image_type': 'message'}
|
||||
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] im 图片响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
return result["data"]["image_key"]
|
||||
return None
|
||||
|
||||
def _try_upload_with_type(self, file_path: str, parent_type: str) -> str:
|
||||
"""尝试不同的 parent_type 上传"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': parent_type,
|
||||
'parent_node': '',
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
print(f"[INFO] 尝试 parent_type={parent_type}")
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 响应: {result}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败 ({parent_type}): {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> dict:
|
||||
"""
|
||||
绑定图片到图片块 (关键的第三步!)
|
||||
|
||||
使用 PATCH 请求和 replace_image 字段将图片绑定到已创建的图片块
|
||||
"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
|
||||
params = {"document_revision_id": -1}
|
||||
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
|
||||
print(f"[INFO] 绑定图片到块: block_id={block_id}")
|
||||
response = requests.patch(url, headers=self.headers, params=params, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定图片失败: {data}")
|
||||
|
||||
print(f"[OK] 图片绑定成功")
|
||||
return data["data"]
|
||||
|
||||
|
||||
def generate_test_image(output_path: str):
|
||||
"""生成测试图片"""
|
||||
img = Image.new('RGB', (400, 300), color='#4a90d9')
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([20, 20, 380, 280], fill='#f0f4f8', outline='#2563eb', width=2)
|
||||
draw.text((200, 150), "Test Image", fill='#1e3a5f', anchor='mm')
|
||||
draw.text((200, 200), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill='#6b7280', anchor='mm')
|
||||
img.save(output_path)
|
||||
print(f"[OK] 测试图片生成: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 飞书云文档图片上传测试")
|
||||
print("#" * 60)
|
||||
|
||||
docx = FeishuDocx()
|
||||
|
||||
# Step 1: 生成测试图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 1: 生成测试图片")
|
||||
print("=" * 50)
|
||||
|
||||
test_image = "/tmp/feishu_docx_test_image.png"
|
||||
generate_test_image(test_image)
|
||||
|
||||
# Step 2: 创建测试文档
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 2: 创建测试文档")
|
||||
print("=" * 50)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
doc = docx.create_document(f"图片上传测试 - {timestamp}")
|
||||
document_id = doc["document_id"]
|
||||
|
||||
# Step 3: 创建空的图片块
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 3: 创建空的图片块")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
block_id = docx.create_empty_image_block(document_id)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 创建图片块失败: {e}")
|
||||
return
|
||||
|
||||
# Step 4: 上传图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 4: 上传图片")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
file_token = docx.upload_image_to_block(test_image, block_id)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 上传图片失败: {e}")
|
||||
return
|
||||
|
||||
# Step 5: 绑定图片到图片块 (关键步骤!)
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 5: 绑定图片到图片块")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
docx.bind_image_to_block(document_id, block_id, file_token)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 绑定图片失败: {e}")
|
||||
return
|
||||
|
||||
# 完成
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n文档地址:")
|
||||
print(f" https://feishu.cn/docx/{document_id}")
|
||||
print()
|
||||
|
||||
|
||||
def check_permissions(docx):
|
||||
"""检查应用权限"""
|
||||
# 获取应用信息
|
||||
url = f"{BASE_URL}/application/v6/applications/underauditlist"
|
||||
headers = {"Authorization": f"Bearer {docx.token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"[DEBUG] 权限检查响应: {response.json()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
375
plugins/feishu-plugin/update_visibility_manual.py
Normal file
375
plugins/feishu-plugin/update_visibility_manual.py
Normal file
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
更新飞书文档:ai-proj 项目可见性手册
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 目标文档
|
||||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def delete_block(document_id: str, block_id: str):
|
||||
"""删除块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||||
return response.json().get("code") == 0
|
||||
|
||||
|
||||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||
"""创建内容块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {"children": blocks}
|
||||
response = requests.post(url, headers=headers(), json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建块失败: {data}")
|
||||
return data["data"].get("children", [])
|
||||
|
||||
|
||||
def create_image_block(document_id: str, parent_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {"replace_image": {"token": file_token}}
|
||||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def generate_visibility_diagram():
|
||||
"""生成可见性示意图"""
|
||||
output_path = "/tmp/visibility_diagram.png"
|
||||
|
||||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
draw.rectangle([0, 0, 800, 50], fill='#1890ff')
|
||||
draw.text((400, 25), "项目可见性示意图", fill='white', anchor='mm')
|
||||
|
||||
# 私有项目
|
||||
draw.rectangle([50, 80, 370, 350], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.text((210, 100), "🔒 私有项目 (private)", fill='#fa8c16', anchor='mm')
|
||||
draw.text((210, 140), "仅创建者可见", fill='#666666', anchor='mm')
|
||||
|
||||
# 私有项目内的人员图标
|
||||
draw.ellipse([180, 180, 240, 240], fill='#fa8c16')
|
||||
draw.text((210, 210), "👤", fill='white', anchor='mm')
|
||||
draw.text((210, 270), "项目所有者", fill='#333333', anchor='mm')
|
||||
draw.text((210, 300), "管理员", fill='#999999', anchor='mm')
|
||||
|
||||
# 企业项目
|
||||
draw.rectangle([430, 80, 750, 350], fill='#e6f7ff', outline='#1890ff', width=2)
|
||||
draw.text((590, 100), "👥 企业项目 (enterprise)", fill='#1890ff', anchor='mm')
|
||||
draw.text((590, 140), "企业内所有成员可见", fill='#666666', anchor='mm')
|
||||
|
||||
# 企业项目内的人员图标组
|
||||
positions = [(520, 190), (590, 190), (660, 190), (555, 250), (625, 250)]
|
||||
for x, y in positions:
|
||||
draw.ellipse([x-20, y-20, x+20, y+20], fill='#1890ff')
|
||||
draw.text((x, y), "👤", fill='white', anchor='mm')
|
||||
|
||||
draw.text((590, 300), "企业所有成员", fill='#333333', anchor='mm')
|
||||
draw.text((590, 330), "管理员", fill='#999999', anchor='mm')
|
||||
|
||||
# 箭头
|
||||
draw.text((400, 200), "→", fill='#333333', anchor='mm')
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_ui_screenshot():
|
||||
"""生成 UI 示意图"""
|
||||
output_path = "/tmp/visibility_ui.png"
|
||||
|
||||
img = Image.new('RGB', (600, 200), color='#fafafa')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
draw.text((20, 20), "项目可见性选择", fill='#333333')
|
||||
|
||||
# Radio Group 模拟
|
||||
# 私有项目选项
|
||||
draw.rectangle([20, 60, 280, 100], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.ellipse([30, 70, 50, 90], fill='#fa8c16')
|
||||
draw.text((60, 80), "🔒 私有项目", fill='#fa8c16', anchor='lm')
|
||||
|
||||
# 企业项目选项
|
||||
draw.rectangle([300, 60, 560, 100], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||
draw.ellipse([310, 70, 330, 90], outline='#d9d9d9', width=1)
|
||||
draw.text((340, 80), "👥 企业项目", fill='#666666', anchor='lm')
|
||||
|
||||
# 说明文字
|
||||
draw.text((20, 130), "私有项目仅创建者可见,企业项目对企业内所有成员可见", fill='#999999')
|
||||
|
||||
# 默认标签
|
||||
draw.rectangle([200, 65, 270, 85], fill='#52c41a')
|
||||
draw.text((235, 75), "默认", fill='white', anchor='mm')
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# 文档内容块定义
|
||||
def heading1(text):
|
||||
return {
|
||||
"block_type": 3,
|
||||
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading2(text):
|
||||
return {
|
||||
"block_type": 4,
|
||||
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading3(text):
|
||||
return {
|
||||
"block_type": 5,
|
||||
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def text_block(content):
|
||||
return {
|
||||
"block_type": 2,
|
||||
"text": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def bullet(content):
|
||||
return {
|
||||
"block_type": 12,
|
||||
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def ordered(content):
|
||||
return {
|
||||
"block_type": 13,
|
||||
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def code_block(content, language="json"):
|
||||
return {
|
||||
"block_type": 14,
|
||||
"code": {
|
||||
"elements": [{"text_run": {"content": content}}],
|
||||
"language": 1 if language == "json" else 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def divider():
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 更新飞书文档:ai-proj 项目可见性手册")
|
||||
print("#" * 60)
|
||||
|
||||
# Step 1: 生成示意图
|
||||
print("\n--- Step 1: 生成示意图 ---")
|
||||
diagram_path = generate_visibility_diagram()
|
||||
ui_path = generate_ui_screenshot()
|
||||
|
||||
# Step 2: 直接追加内容(不删除现有内容)
|
||||
print("\n--- Step 2: 追加手册内容 ---")
|
||||
print("\n--- Step 3: 创建手册内容 ---")
|
||||
|
||||
# 定义手册内容
|
||||
manual_blocks = [
|
||||
heading1("ai-proj 项目可见性手册"),
|
||||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||
divider(),
|
||||
|
||||
heading2("1. 功能概述"),
|
||||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||
divider(),
|
||||
|
||||
heading2("2. 可见性对比"),
|
||||
]
|
||||
|
||||
# 创建第一批内容块
|
||||
created = create_blocks(DOCUMENT_ID, DOCUMENT_ID, manual_blocks)
|
||||
print(f" 创建了 {len(created)} 个内容块")
|
||||
|
||||
# Step 4: 插入可见性示意图
|
||||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||
img_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
|
||||
print(f" 图片块: {img_block_id}")
|
||||
file_token = upload_image(diagram_path, img_block_id)
|
||||
print(f" file_token: {file_token}")
|
||||
bind_image(DOCUMENT_ID, img_block_id, file_token)
|
||||
print(" 图片绑定成功!")
|
||||
|
||||
# Step 5: 继续添加内容
|
||||
print("\n--- Step 5: 添加更多内容 ---")
|
||||
more_blocks = [
|
||||
divider(),
|
||||
heading2("3. 设置项目可见性"),
|
||||
|
||||
heading3("3.1 创建项目时设置"),
|
||||
ordered("点击「创建项目」按钮"),
|
||||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||
ordered("选择「私有项目」或「企业项目」"),
|
||||
ordered("填写其他信息后点击「确定」"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, more_blocks)
|
||||
|
||||
# Step 6: 插入 UI 示意图
|
||||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||
ui_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
|
||||
ui_file_token = upload_image(ui_path, ui_block_id)
|
||||
bind_image(DOCUMENT_ID, ui_block_id, ui_file_token)
|
||||
print(" UI 示意图上传成功!")
|
||||
|
||||
# Step 7: 添加剩余内容
|
||||
print("\n--- Step 7: 添加剩余内容 ---")
|
||||
final_blocks = [
|
||||
heading3("3.2 修改已有项目"),
|
||||
ordered("在项目列表中找到目标项目"),
|
||||
ordered("点击项目卡片上的「编辑」按钮"),
|
||||
ordered("在编辑页面修改「项目可见性」"),
|
||||
ordered("点击「保存」"),
|
||||
divider(),
|
||||
|
||||
heading2("4. 可见性规则"),
|
||||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||
divider(),
|
||||
|
||||
heading2("5. API 参考"),
|
||||
text_block("修改项目可见性的 API:"),
|
||||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||
|
||||
请求体:
|
||||
{
|
||||
"visibility": "enterprise"
|
||||
}
|
||||
|
||||
可选值: "private" | "enterprise"'''),
|
||||
divider(),
|
||||
|
||||
heading2("6. 注意事项"),
|
||||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||
divider(),
|
||||
|
||||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, final_blocks)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("文档更新完成!")
|
||||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
212
plugins/feishu-plugin/upload_user_image.py
Normal file
212
plugins/feishu-plugin/upload_user_image.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
上传用户指定的图片到飞书云文档
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 用户指定的图片
|
||||
IMAGE_PATH = "/Users/donglinlai/Downloads/u274.png"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def set_document_permission(document_id: str, editable: bool = True):
|
||||
"""
|
||||
设置文档权限
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
editable: True=组织内可编辑, False=组织内只读
|
||||
"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
|
||||
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
print(f" 权限设置成功: {'组织内可编辑' if editable else '组织内只读'}")
|
||||
return True
|
||||
else:
|
||||
print(f" [WARN] 权限设置失败: {data.get('msg')}")
|
||||
return False
|
||||
|
||||
|
||||
def create_document(title: str, editable: bool = True):
|
||||
"""创建文档并设置权限"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
response = requests.post(url, headers=headers(), json={"title": title})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
|
||||
# 自动设置权限
|
||||
if editable:
|
||||
set_document_permission(document_id, editable=True)
|
||||
|
||||
return document_id
|
||||
|
||||
|
||||
def create_image_block(document_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# 根据扩展名设置 MIME 类型
|
||||
ext = file_name.lower().split('.')[-1]
|
||||
mime_types = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp'
|
||||
}
|
||||
mime_type = mime_types.get(ext, 'image/png')
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, mime_type)}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
response = requests.patch(
|
||||
url,
|
||||
headers=headers(),
|
||||
params={"document_revision_id": -1},
|
||||
json=payload
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n上传图片: {IMAGE_PATH}")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查文件
|
||||
if not os.path.exists(IMAGE_PATH):
|
||||
print(f"[ERROR] 文件不存在: {IMAGE_PATH}")
|
||||
return
|
||||
|
||||
file_size = os.path.getsize(IMAGE_PATH)
|
||||
print(f"文件大小: {file_size / 1024:.1f} KB")
|
||||
|
||||
# Step 1: 创建文档
|
||||
print("\n[1/4] 创建飞书文档...")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
doc_id = create_document(f"火把图片 - {timestamp}")
|
||||
print(f" 文档ID: {doc_id}")
|
||||
|
||||
# Step 2: 创建图片块
|
||||
print("[2/4] 创建图片块...")
|
||||
block_id = create_image_block(doc_id)
|
||||
print(f" 块ID: {block_id}")
|
||||
|
||||
# Step 3: 上传图片
|
||||
print("[3/4] 上传图片...")
|
||||
file_token = upload_image(IMAGE_PATH, block_id)
|
||||
print(f" file_token: {file_token}")
|
||||
|
||||
# Step 4: 绑定图片
|
||||
print("[4/4] 绑定图片...")
|
||||
bind_image(doc_id, block_id, file_token)
|
||||
print(" 绑定成功!")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("上传完成!")
|
||||
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
plugins/feishu-plugin/users.json
Normal file
10
plugins/feishu-plugin/users.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"吴薇儿": {
|
||||
"email": "wuweier@zhiyuncai.com",
|
||||
"open_id": "ou_1d5cdfee78cbe6f8acc0751fff00ed09"
|
||||
},
|
||||
"宋佳香": {
|
||||
"email": "songjiaxiang@zhiyuncai.com",
|
||||
"open_id": "e6e72eb8"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user