Files

537 lines
19 KiB
Python
Raw Permalink 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
"""
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))