Files
ai-proj-helper/plugins/feishu-plugin/scripts/aiproj_sync.py

531 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()