zhoujiaangyao commited on
Commit ·
2c9f2fe
1
Parent(s): 3985de6
feat(feishu): 支持导入到知识库(Wiki)
Browse files
backend/app/routers/feishu.py
CHANGED
|
@@ -28,6 +28,7 @@ class FeishuConfigRequest(BaseModel):
|
|
| 28 |
app_id: Optional[str] = None
|
| 29 |
app_secret: Optional[str] = None
|
| 30 |
folder_token: Optional[str] = None
|
|
|
|
| 31 |
base_url: Optional[str] = None
|
| 32 |
auto_push: Optional[bool] = None
|
| 33 |
enabled: Optional[bool] = None
|
|
@@ -108,6 +109,7 @@ def update_feishu_config(data: FeishuConfigRequest):
|
|
| 108 |
app_id=data.app_id,
|
| 109 |
app_secret=data.app_secret,
|
| 110 |
folder_token=data.folder_token,
|
|
|
|
| 111 |
base_url=data.base_url,
|
| 112 |
push_backend=data.push_backend,
|
| 113 |
cli_path=data.cli_path,
|
|
|
|
| 28 |
app_id: Optional[str] = None
|
| 29 |
app_secret: Optional[str] = None
|
| 30 |
folder_token: Optional[str] = None
|
| 31 |
+
wiki_token: Optional[str] = None # 知识库节点链接/token,填了就导入到该 wiki 节点下
|
| 32 |
base_url: Optional[str] = None
|
| 33 |
auto_push: Optional[bool] = None
|
| 34 |
enabled: Optional[bool] = None
|
|
|
|
| 109 |
app_id=data.app_id,
|
| 110 |
app_secret=data.app_secret,
|
| 111 |
folder_token=data.folder_token,
|
| 112 |
+
wiki_token=data.wiki_token,
|
| 113 |
base_url=data.base_url,
|
| 114 |
push_backend=data.push_backend,
|
| 115 |
cli_path=data.cli_path,
|
backend/app/services/feishu_config_manager.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
from typing import Any, Dict, Optional
|
| 3 |
|
| 4 |
from app.db.app_config_dao import load_value, set_value
|
|
@@ -8,6 +9,20 @@ from app.db.app_config_dao import load_value, set_value
|
|
| 8 |
DEFAULT_FEISHU_BASE_URL = "https://open.feishu.cn"
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
class FeishuConfigManager:
|
| 12 |
"""飞书(Lark)文档推送配置,存 JSON 文件,前端可动态修改。
|
| 13 |
|
|
@@ -48,6 +63,8 @@ class FeishuConfigManager:
|
|
| 48 |
"app_id": (data.get("app_id") or "").strip(),
|
| 49 |
"app_secret": (data.get("app_secret") or "").strip(),
|
| 50 |
"folder_token": (data.get("folder_token") or "").strip(),
|
|
|
|
|
|
|
| 51 |
"base_url": base_url.rstrip("/"),
|
| 52 |
"push_backend": backend,
|
| 53 |
"cli_path": (data.get("cli_path") or "lark-cli").strip() or "lark-cli",
|
|
@@ -68,6 +85,7 @@ class FeishuConfigManager:
|
|
| 68 |
app_id: Optional[str] = None,
|
| 69 |
app_secret: Optional[str] = None,
|
| 70 |
folder_token: Optional[str] = None,
|
|
|
|
| 71 |
base_url: Optional[str] = None,
|
| 72 |
push_backend: Optional[str] = None,
|
| 73 |
cli_path: Optional[str] = None,
|
|
@@ -85,6 +103,8 @@ class FeishuConfigManager:
|
|
| 85 |
data["app_secret"] = app_secret.strip()
|
| 86 |
if folder_token is not None:
|
| 87 |
data["folder_token"] = folder_token.strip()
|
|
|
|
|
|
|
| 88 |
if base_url is not None:
|
| 89 |
data["base_url"] = base_url.strip()
|
| 90 |
if push_backend is not None:
|
|
|
|
| 1 |
import os
|
| 2 |
+
import re
|
| 3 |
from typing import Any, Dict, Optional
|
| 4 |
|
| 5 |
from app.db.app_config_dao import load_value, set_value
|
|
|
|
| 9 |
DEFAULT_FEISHU_BASE_URL = "https://open.feishu.cn"
|
| 10 |
|
| 11 |
|
| 12 |
+
def _extract_wiki_token(value: str) -> str:
|
| 13 |
+
"""从飞书知识库链接或原始 token 取出 wiki 节点 token。
|
| 14 |
+
|
| 15 |
+
支持直接粘贴整条链接(https://xxx.feishu.cn/wiki/XmOJ...)或只填 token。
|
| 16 |
+
"""
|
| 17 |
+
v = (value or "").strip()
|
| 18 |
+
if not v:
|
| 19 |
+
return ""
|
| 20 |
+
m = re.search(r"/wiki/([A-Za-z0-9]+)", v)
|
| 21 |
+
if m:
|
| 22 |
+
return m.group(1)
|
| 23 |
+
return v.split("?")[0].split("/")[-1].strip()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
class FeishuConfigManager:
|
| 27 |
"""飞书(Lark)文档推送配置,存 JSON 文件,前端可动态修改。
|
| 28 |
|
|
|
|
| 63 |
"app_id": (data.get("app_id") or "").strip(),
|
| 64 |
"app_secret": (data.get("app_secret") or "").strip(),
|
| 65 |
"folder_token": (data.get("folder_token") or "").strip(),
|
| 66 |
+
# 知识库节点 token:填了就把笔记导入到该 wiki 节点下(否则导入云空间文件夹)
|
| 67 |
+
"wiki_token": _extract_wiki_token(data.get("wiki_token") or ""),
|
| 68 |
"base_url": base_url.rstrip("/"),
|
| 69 |
"push_backend": backend,
|
| 70 |
"cli_path": (data.get("cli_path") or "lark-cli").strip() or "lark-cli",
|
|
|
|
| 85 |
app_id: Optional[str] = None,
|
| 86 |
app_secret: Optional[str] = None,
|
| 87 |
folder_token: Optional[str] = None,
|
| 88 |
+
wiki_token: Optional[str] = None,
|
| 89 |
base_url: Optional[str] = None,
|
| 90 |
push_backend: Optional[str] = None,
|
| 91 |
cli_path: Optional[str] = None,
|
|
|
|
| 103 |
data["app_secret"] = app_secret.strip()
|
| 104 |
if folder_token is not None:
|
| 105 |
data["folder_token"] = folder_token.strip()
|
| 106 |
+
if wiki_token is not None:
|
| 107 |
+
data["wiki_token"] = _extract_wiki_token(wiki_token)
|
| 108 |
if base_url is not None:
|
| 109 |
data["base_url"] = base_url.strip()
|
| 110 |
if push_backend is not None:
|
backend/app/services/feishu_service.py
CHANGED
|
@@ -56,6 +56,8 @@ class FeishuService:
|
|
| 56 |
self.app_id = (self.cfg.get("app_id") or "").strip()
|
| 57 |
self.app_secret = (self.cfg.get("app_secret") or "").strip()
|
| 58 |
self.folder_token = (self.cfg.get("folder_token") or "").strip()
|
|
|
|
|
|
|
| 59 |
|
| 60 |
@property
|
| 61 |
def _cache_key(self) -> Tuple[str, str]:
|
|
@@ -165,6 +167,11 @@ class FeishuService:
|
|
| 165 |
result = self._poll_import_task(ticket)
|
| 166 |
token = result.get("token") or ""
|
| 167 |
doc_type = result.get("type") or "docx"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
url = result.get("url") or self._fallback_doc_url(doc_type, token)
|
| 169 |
logger.info(f"飞书导入成功:{safe_title} -> {url}")
|
| 170 |
return {"url": url, "token": token, "type": doc_type, "title": safe_title}
|
|
@@ -279,6 +286,85 @@ class FeishuService:
|
|
| 279 |
host = self.base_url.replace("https://open.", "https://").replace("http://open.", "http://")
|
| 280 |
return f"{host}/{doc_type}/{token}"
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
# ─── Markdown 预处理 ──────────────────────────────────────────────────────
|
| 283 |
@staticmethod
|
| 284 |
def _safe_title(title: str, fallback: str = "VideoMemo 笔记") -> str:
|
|
|
|
| 56 |
self.app_id = (self.cfg.get("app_id") or "").strip()
|
| 57 |
self.app_secret = (self.cfg.get("app_secret") or "").strip()
|
| 58 |
self.folder_token = (self.cfg.get("folder_token") or "").strip()
|
| 59 |
+
# 知识库节点 token:填了就把笔记导入到该 wiki 节点下(先导云空间再移动挂载)
|
| 60 |
+
self.wiki_token = (self.cfg.get("wiki_token") or "").strip()
|
| 61 |
|
| 62 |
@property
|
| 63 |
def _cache_key(self) -> Tuple[str, str]:
|
|
|
|
| 167 |
result = self._poll_import_task(ticket)
|
| 168 |
token = result.get("token") or ""
|
| 169 |
doc_type = result.get("type") or "docx"
|
| 170 |
+
# 配了知识库节点 → 把这篇 docx 移动/挂载到该 wiki 节点下
|
| 171 |
+
if self.wiki_token:
|
| 172 |
+
moved = self._move_to_wiki(token)
|
| 173 |
+
logger.info(f"飞书导入知识库成功:{safe_title} -> {moved['url']}")
|
| 174 |
+
return {**moved, "title": safe_title}
|
| 175 |
url = result.get("url") or self._fallback_doc_url(doc_type, token)
|
| 176 |
logger.info(f"飞书导入成功:{safe_title} -> {url}")
|
| 177 |
return {"url": url, "token": token, "type": doc_type, "title": safe_title}
|
|
|
|
| 286 |
host = self.base_url.replace("https://open.", "https://").replace("http://open.", "http://")
|
| 287 |
return f"{host}/{doc_type}/{token}"
|
| 288 |
|
| 289 |
+
def _site_host(self) -> str:
|
| 290 |
+
return self.base_url.replace("https://open.", "https://").replace("http://open.", "http://")
|
| 291 |
+
|
| 292 |
+
# ─── 知识库(Wiki)导入:把已生成的 docx 移动/挂载到 wiki 节点下 ────────────
|
| 293 |
+
def _wiki_space_id(self, node_token: str) -> str:
|
| 294 |
+
"""由 wiki 节点 token 解析其所属知识库 space_id。"""
|
| 295 |
+
url = f"{self.base_url}/open-apis/wiki/v2/spaces/get_node"
|
| 296 |
+
try:
|
| 297 |
+
resp = requests.get(
|
| 298 |
+
url, headers=self._auth_headers(), params={"token": node_token}, timeout=DEFAULT_TIMEOUT
|
| 299 |
+
)
|
| 300 |
+
payload = resp.json()
|
| 301 |
+
except Exception as exc:
|
| 302 |
+
raise FeishuError(f"解析知识库节点失败:{exc}") from exc
|
| 303 |
+
if payload.get("code") != 0:
|
| 304 |
+
raise FeishuError(
|
| 305 |
+
self._fmt_api_error("解析知识库节点失败", payload)
|
| 306 |
+
+ "。请确认「知识库节点」token 正确,且应用已加入该知识库并有编辑权限"
|
| 307 |
+
)
|
| 308 |
+
node = (payload.get("data") or {}).get("node") or {}
|
| 309 |
+
space_id = node.get("space_id")
|
| 310 |
+
if not space_id:
|
| 311 |
+
raise FeishuError(f"知识库节点缺少 space_id({payload})")
|
| 312 |
+
return space_id
|
| 313 |
+
|
| 314 |
+
def _move_to_wiki(self, docx_token: str) -> Dict[str, Any]:
|
| 315 |
+
"""把 docx 移动/挂载到配置的 wiki 节点下,返回 {url, token, type}。"""
|
| 316 |
+
space_id = self._wiki_space_id(self.wiki_token)
|
| 317 |
+
url = f"{self.base_url}/open-apis/wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki"
|
| 318 |
+
body = {
|
| 319 |
+
"parent_wiki_token": self.wiki_token,
|
| 320 |
+
"obj_type": "docx",
|
| 321 |
+
"obj_token": docx_token,
|
| 322 |
+
"apply": True,
|
| 323 |
+
}
|
| 324 |
+
try:
|
| 325 |
+
resp = requests.post(
|
| 326 |
+
url,
|
| 327 |
+
headers={**self._auth_headers(), "Content-Type": "application/json"},
|
| 328 |
+
json=body,
|
| 329 |
+
timeout=DEFAULT_TIMEOUT,
|
| 330 |
+
)
|
| 331 |
+
payload = resp.json()
|
| 332 |
+
except Exception as exc:
|
| 333 |
+
raise FeishuError(f"移动文档到知识库失败:{exc}") from exc
|
| 334 |
+
if payload.get("code") != 0:
|
| 335 |
+
raise FeishuError(
|
| 336 |
+
self._fmt_api_error("移动文档到知识库失败", payload)
|
| 337 |
+
+ "。请确认应用已开通 wiki 权限、已加入该知识库并有编辑权限"
|
| 338 |
+
)
|
| 339 |
+
data = payload.get("data") or {}
|
| 340 |
+
wiki_token = data.get("wiki_token")
|
| 341 |
+
if not wiki_token and data.get("task_id"):
|
| 342 |
+
wiki_token = self._poll_wiki_move(data["task_id"])
|
| 343 |
+
if not wiki_token:
|
| 344 |
+
raise FeishuError(f"知识库导入异常:未拿到 wiki_token({payload})")
|
| 345 |
+
return {"url": f"{self._site_host()}/wiki/{wiki_token}", "token": wiki_token, "type": "wiki"}
|
| 346 |
+
|
| 347 |
+
def _poll_wiki_move(self, task_id: str) -> str:
|
| 348 |
+
"""move_docs_to_wiki 异步时轮询任务,返回新 wiki_token。"""
|
| 349 |
+
url = f"{self.base_url}/open-apis/wiki/v2/tasks/{task_id}"
|
| 350 |
+
for _ in range(_POLL_MAX_ATTEMPTS):
|
| 351 |
+
try:
|
| 352 |
+
resp = requests.get(
|
| 353 |
+
url, headers=self._auth_headers(), params={"task_type": "move"}, timeout=DEFAULT_TIMEOUT
|
| 354 |
+
)
|
| 355 |
+
payload = resp.json()
|
| 356 |
+
except Exception as exc:
|
| 357 |
+
raise FeishuError(f"查询知识库导入结果失败:{exc}") from exc
|
| 358 |
+
if payload.get("code") != 0:
|
| 359 |
+
raise FeishuError(self._fmt_api_error("查询知识库导入结果失败", payload))
|
| 360 |
+
task = (payload.get("data") or {}).get("task") or {}
|
| 361 |
+
for item in task.get("move_result") or []:
|
| 362 |
+
wt = (item.get("node") or {}).get("wiki_token")
|
| 363 |
+
if wt:
|
| 364 |
+
return wt
|
| 365 |
+
time.sleep(_POLL_INTERVAL)
|
| 366 |
+
raise FeishuError("知识库导入超时,请稍后到知识库查看")
|
| 367 |
+
|
| 368 |
# ─── Markdown 预处理 ──────────────────────────────────────────────────────
|
| 369 |
@staticmethod
|
| 370 |
def _safe_title(title: str, fallback: str = "VideoMemo 笔记") -> str:
|