537 lines
19 KiB
Python
537 lines
19 KiB
Python
#!/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))
|