#!/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 ") 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()