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: