#!/usr/bin/env python3 """ Feishu Smart Doc Writer - 改进版 自动分块写入 + 自动转移所有权 + 自动更新索引 """ import re import time import json import asyncio from typing import List, Optional from dataclasses import dataclass # 导入索引管理器 try: from .index_manager import IndexManager, add_doc_to_index except ImportError: from index_manager import IndexManager, add_doc_to_index @dataclass class ChunkConfig: """分块配置""" chunk_size: int = 2000 # 每块最大字符数 max_retries: int = 3 # 最大重试次数 retry_delay: float = 1.0 # 重试间隔(秒) show_progress: bool = True # 显示进度 convert_tables: bool = True # 转换表格为文本 class ContentChunker: """内容分块器""" def __init__(self, config: ChunkConfig = None): self.config = config or ChunkConfig() def chunk_content(self, content: str) -> List[str]: """ 将长内容分割成多个小块 策略:按段落分割,如果段落超过限制,按句子分割 """ chunks = [] current_chunk = "" # 先处理表格 if self.config.convert_tables: content = self._convert_tables(content) # 按段落分割 paragraphs = self._split_paragraphs(content) for para in paragraphs: # 如果当前块加上新段落会超限 if len(current_chunk) + len(para) > self.config.chunk_size: # 保存当前块 if current_chunk.strip(): chunks.append(current_chunk.strip()) # 如果单个段落就超限,需要进一步分割 if len(para) > self.config.chunk_size: sub_chunks = self._split_large_paragraph(para) chunks.extend(sub_chunks) current_chunk = "" else: current_chunk = para else: current_chunk += "\n\n" + para if current_chunk else para # 保存最后一块 if current_chunk.strip(): chunks.append(current_chunk.strip()) return chunks def _convert_tables(self, content: str) -> str: """将Markdown表格转换为文本列表""" table_pattern = r'\|[^\n]+\|\n\|[-:| ]+\|\n((?:\|[^\n]+\|\n)+)' def convert_table(match): table_text = match.group(0) lines = table_text.strip().split('\n') # 提取表头 header = [cell.strip() for cell in lines[0].split('|')[1:-1]] # 提取数据行 result = ["【表格内容】"] for line in lines[2:]: # 跳过表头和分隔线 cells = [cell.strip() for cell in line.split('|')[1:-1]] if cells and any(cells): # 确保不是空行 row_text = ", ".join([f"{h}: {c}" for h, c in zip(header, cells)]) result.append(f"- {row_text}") return "\n".join(result) return re.sub(table_pattern, convert_table, content) def _split_paragraphs(self, content: str) -> List[str]: """按段落分割,保留标题结构""" lines = content.split('\n') paragraphs = [] current_para = "" for line in lines: stripped = line.strip() if not stripped: if current_para.strip(): paragraphs.append(current_para.strip()) current_para = "" continue # 如果是标题,单独成段 if stripped.startswith('#'): if current_para.strip(): paragraphs.append(current_para.strip()) current_para = "" paragraphs.append(stripped) else: current_para += line + "\n" if current_para.strip(): paragraphs.append(current_para.strip()) return paragraphs def _split_large_paragraph(self, para: str) -> List[str]: """分割大段落(按句子)""" chunks = [] sentences = re.split(r'([。!?.\n])', para) current = "" for i in range(0, len(sentences), 2): sentence = sentences[i] if i + 1 < len(sentences): sentence += sentences[i + 1] # 加上标点 if len(current) + len(sentence) > self.config.chunk_size: if current.strip(): chunks.append(current.strip()) current = sentence else: current += sentence if current.strip(): chunks.append(current.strip()) return chunks class FeishuDocWriter: """ 飞书文档智能写入器 使用 OpenClaw 官方工具调用方式 """ def __init__(self, ctx=None, config: ChunkConfig = None): """ 初始化 Args: ctx: OpenClaw 上下文对象(在 Skill 中传入) config: 分块配置 """ self.ctx = ctx self.config = config or ChunkConfig() self.chunker = ContentChunker(config) async def write_document(self, title: str, content: str, folder_token: str = None) -> str: """ 创建新文档并写入内容(自动分块) Args: title: 文档标题 content: 文档内容(支持长内容,自动分块) folder_token: 可选的文件夹token Returns: 文档URL """ if not self.ctx: raise ValueError("需要提供 OpenClaw 上下文对象 (ctx)") # 第一步:创建空文档(只传标题) doc_token = await self._create_empty_doc(title, folder_token) doc_url = f"https://feishu.cn/docx/{doc_token}" if self.config.show_progress: print(f"✅ 文档创建成功: {doc_url}") # 第二步:分批追加内容 success = await self._write_content_in_chunks(doc_token, content) if not success: raise Exception("写入内容失败") return doc_url async def append_to_document(self, doc_url: str, content: str) -> bool: """ 追加内容到现有文档(自动分块) Args: doc_url: 文档URL content: 要追加的内容 Returns: 是否成功 """ if not self.ctx: raise ValueError("需要提供 OpenClaw 上下文对象 (ctx)") doc_token = self._extract_token_from_url(doc_url) return await self._write_content_in_chunks(doc_token, content) async def _create_empty_doc(self, title: str, folder_token: str = None) -> str: """创建空文档,只传标题""" try: # 使用 OpenClaw 官方工具调用方式 result = await self.ctx.invoke_tool("feishu_doc.create", { "title": title, "folder_token": folder_token }) # 提取 doc_token if isinstance(result, dict): doc_token = result.get("document_id") or result.get("doc_token") if doc_token: return doc_token # 如果是字符串,尝试提取 if isinstance(result, str): import re match = re.search(r'docx/([a-zA-Z0-9]+)', result) if match: return match.group(1) return result raise Exception(f"无法解析文档token: {result}") except Exception as e: raise Exception(f"创建文档失败: {e}") async def _write_content_in_chunks(self, doc_token: str, content: str) -> bool: """分批写入内容""" chunks = self.chunker.chunk_content(content) if self.config.show_progress: print(f"📝 内容已分割为 {len(chunks)} 块,开始写入...") for i, chunk in enumerate(chunks, 1): if self.config.show_progress: print(f" 写入第 {i}/{len(chunks)} 块 ({len(chunk)} 字符)...") success = await self._append_chunk_with_retry(doc_token, chunk) if not success: print(f"❌ 第 {i} 块写入失败") return False # 添加小延迟,避免API限流 if i < len(chunks): await asyncio.sleep(0.5) if self.config.show_progress: print(f"✅ 全部 {len(chunks)} 块写入完成") return True async def _append_chunk_with_retry(self, doc_token: str, chunk: str) -> bool: """带重试的追加内容""" for attempt in range(self.config.max_retries): try: return await self._append_chunk(doc_token, chunk) except Exception as e: if self.config.show_progress: print(f" 尝试 {attempt + 1}/{self.config.max_retries} 失败: {e}") if attempt < self.config.max_retries - 1: await asyncio.sleep(self.config.retry_delay * (attempt + 1)) else: return False return False async def _append_chunk(self, doc_token: str, chunk: str) -> bool: """追加单块内容""" try: # 使用 OpenClaw 官方工具调用方式 await self.ctx.invoke_tool("feishu_doc.append", { "doc_token": doc_token, "content": chunk }) return True except Exception as e: raise Exception(f"API调用失败: {e}") def _extract_token_from_url(self, url: str) -> str: """从URL中提取doc_token""" import re match = re.search(r'docx/([a-zA-Z0-9]+)', url) if match: return match.group(1) raise ValueError(f"无法从URL提取token: {url}") async def _get_tenant_access_token(self) -> str: """获取飞书 tenant_access_token""" import aiohttp import json import os # 尝试从 OpenClaw 配置读取 App ID 和 Secret app_id, app_secret = None, None # 方法1: 尝试从环境变量读取 app_id = os.environ.get("FEISHU_APP_ID") app_secret = os.environ.get("FEISHU_APP_SECRET") # 方法2: 尝试从 OpenClaw 配置文件读取 if not app_id or not app_secret: try: config_paths = [ os.path.expanduser("~/.openclaw/openclaw.json"), os.path.expanduser("~/.openclaw/config.json"), ] for config_path in config_paths: if os.path.exists(config_path): with open(config_path, 'r') as f: config = json.load(f) feishu_config = config.get("channels", {}).get("feishu", {}) app_id = feishu_config.get("appId") app_secret = feishu_config.get("appSecret") if app_id and app_secret: break except Exception: pass if not app_id or not app_secret: return "" # 调用飞书 API 获取 token url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" payload = { "app_id": app_id, "app_secret": app_secret } try: async with aiohttp.ClientSession() as session: async with session.post(url, json=payload) as resp: result = await resp.json() if result.get("code") == 0: return result.get("tenant_access_token", "") except Exception: pass return "" async def transfer_ownership(self, doc_url: str, owner_openid: str) -> bool: """ 转移文档所有权 - 直接调用飞书 API API端点: POST /drive/v1/permissions/{token}/members/transfer_owner?type=docx Args: doc_url: 文档URL owner_openid: 新所有者的openid (例如: ou_xxxxxxxx) Returns: 是否成功 """ import aiohttp import json doc_token = self._extract_token_from_url(doc_url) # 获取 tenant_access_token token = await self._get_tenant_access_token() if not token: if self.config.show_progress: print(f"⚠️ 无法获取 tenant_access_token") return False # 调用飞书 API 转移所有权 url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{doc_token}/members/transfer_owner?type=docx" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } payload = { "member_type": "openid", "member_id": owner_openid } try: async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=payload) as resp: result = await resp.json() if result.get("code") == 0: if self.config.show_progress: print(f"✅ 文档所有权已转移给 {owner_openid}") return True else: error_msg = result.get("msg", "未知错误") if self.config.show_progress: print(f"⚠️ 所有权转移失败: {error_msg}") return False except Exception as e: if self.config.show_progress: print(f"⚠️ 所有权转移失败: {e}") return False async def write_document_with_transfer( self, title: str, content: str, folder_token: str = None, owner_openid: str = None ) -> dict: """ 创建文档并写入内容,完成后自动转移所有权并更新本地索引 Args: title: 文档标题 content: 文档内容 folder_token: 可选的文件夹token owner_openid: 新所有者的openid,如果提供则自动转移所有权 Returns: { "doc_url": "...", "doc_token": "...", "chunks_count": N, "owner_transferred": True/False, "index_updated": True/False } """ # 1. 创建并写入文档 doc_url = await self.write_document(title, content, folder_token) # 2. 提取 doc_token doc_token = self._extract_token_from_url(doc_url) # 3. 计算分块数 chunks = self.chunker.chunk_content(content) # 4. 转移所有权(如果提供了 owner_openid) owner_transferred = False if owner_openid: owner_transferred = await self.transfer_ownership(doc_url, owner_openid) # 5. 【关键】自动更新本地索引 index_updated = False try: # 生成摘要(取前100字) summary = content[:100].replace('\n', ' ') + "..." if len(content) > 100 else content # 自动分类标签 tags = self._auto_classify_content(content, title) # 更新索引 index_updated = add_doc_to_index( name=title, url=doc_url, token=doc_token, summary=summary, tags=tags, owner=owner_openid or "" ) if self.config.show_progress and index_updated: print(f"✅ 文档索引已更新") elif self.config.show_progress: print(f"⚠️ 文档索引更新失败(不影响文档创建)") except Exception as e: if self.config.show_progress: print(f"⚠️ 索引更新失败: {e}(不影响文档创建)") return { "doc_url": doc_url, "doc_token": doc_token, "chunks_count": len(chunks), "owner_transferred": owner_transferred, "index_updated": index_updated } def _auto_classify_content(self, content: str, title: str) -> List[str]: """根据内容自动分类""" tags = [] text = (title + " " + content).lower() # 关键词映射到标签 if any(k in text for k in ["ai", "人工智能", "模型", "gpt", "llm"]): tags.append("AI技术") if any(k in text for k in ["openclaw", "skill", "agent"]): tags.append("OpenClaw") if any(k in text for k in ["飞书", "文档", "feishu", "docx"]): tags.append("飞书文档") if any(k in text for k in ["电商", "tiktok", "alibaba", "玩具"]): tags.append("电商") if any(k in text for k in ["garmin", "strava", "骑行", "健康", "运动"]): tags.append("健康运动") if any(k in text for k in ["对话", "归档", "聊天记录"]): tags.append("每日归档") # 如果没有匹配到特定标签,添加通用标签 if not tags: tags.append("其他") return tags # 同步包装函数(方便非异步环境使用) def write_document_sync(ctx, title: str, content: str, folder_token: str = None, config: ChunkConfig = None) -> str: """同步方式写入文档""" writer = FeishuDocWriter(ctx, config) return asyncio.run(writer.write_document(title, content, folder_token)) def write_document_with_transfer_sync( ctx, title: str, content: str, folder_token: str = None, owner_openid: str = None, config: ChunkConfig = None ) -> dict: """同步方式写入文档并转移所有权""" writer = FeishuDocWriter(ctx, config) return asyncio.run(writer.write_document_with_transfer(title, content, folder_token, owner_openid)) def append_to_document_sync(ctx, doc_url: str, content: str, config: ChunkConfig = None) -> bool: """同步方式追加文档""" writer = FeishuDocWriter(ctx, config) return asyncio.run(writer.append_to_document(doc_url, content))