feishu_doc.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. """
  2. 飞书文档工具 — auto_put_ad_mini
  3. 职责:
  4. - 将本地 xlsx 文件上传并导入为飞书在线表格
  5. - 设置文档权限(任何人获得链接可查看 - 支持不同主体访问)
  6. - 通过 IM 发送在线表格链接
  7. 飞书 Drive API(通过 httpx 直连):
  8. - 上传素材:POST /drive/v1/medias/upload_all
  9. - 创建导入任务:POST /drive/v1/import_tasks
  10. - 查询导入结果:GET /drive/v1/import_tasks/{ticket}
  11. - 设置权限:PATCH /drive/v1/permissions/{token}/public
  12. 飞书 IM(通过框架 FeishuClient):
  13. - 发送链接消息:send_message(to, text)
  14. """
  15. import json
  16. import logging
  17. import sys
  18. import time
  19. from datetime import datetime
  20. from pathlib import Path
  21. from typing import Dict, Optional
  22. import httpx
  23. from agent.tools import tool
  24. from agent.tools.models import ToolContext, ToolResult
  25. from agent.tools.builtin.feishu.feishu_client import FeishuClient
  26. _MINI_DIR = Path(__file__).resolve().parent.parent
  27. _TOOLS_DIR = Path(__file__).resolve().parent
  28. if str(_MINI_DIR) not in sys.path:
  29. sys.path.insert(0, str(_MINI_DIR))
  30. if str(_TOOLS_DIR) not in sys.path:
  31. sys.path.insert(0, str(_TOOLS_DIR))
  32. from config import (
  33. FEISHU_APP_ID,
  34. FEISHU_APP_SECRET,
  35. FEISHU_OPERATOR_CHAT_ID,
  36. REPORTS_DIR,
  37. )
  38. logger = logging.getLogger(__name__)
  39. # ═══════════════════════════════════════════
  40. # 常量
  41. # ═══════════════════════════════════════════
  42. FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
  43. _HTTP_TIMEOUT = 30
  44. _IMPORT_POLL_INTERVAL = 2 # 秒
  45. _IMPORT_MAX_WAIT = 60 # 秒
  46. # 全局 FeishuClient(复用框架能力发 IM 消息)
  47. _feishu = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
  48. # token 缓存
  49. _token_cache: Dict[str, object] = {}
  50. _TOKEN_TTL = 1500 # 25 分钟(官方有效期 2 小时,留余量)
  51. # ═══════════════════════════════════════════
  52. # 内部方法:飞书 tenant_access_token
  53. # ═══════════════════════════════════════════
  54. def _get_tenant_token() -> str:
  55. """获取飞书 tenant_access_token(带缓存)"""
  56. cached = _token_cache.get("tenant")
  57. if cached and time.time() - cached["ts"] < _TOKEN_TTL:
  58. return cached["token"]
  59. resp = httpx.post(
  60. f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
  61. json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
  62. timeout=_HTTP_TIMEOUT,
  63. )
  64. data = resp.json()
  65. if data.get("code") != 0:
  66. raise RuntimeError(f"获取飞书 token 失败: {data}")
  67. token = data["tenant_access_token"]
  68. _token_cache["tenant"] = {"token": token, "ts": time.time()}
  69. logger.info("飞书 tenant_access_token 已刷新")
  70. return token
  71. def _auth_headers(token: str) -> Dict[str, str]:
  72. return {"Authorization": f"Bearer {token}"}
  73. # ═══════════════════════════════════════════
  74. # 内部方法:Drive API
  75. # ═══════════════════════════════════════════
  76. def _upload_media(token: str, file_path: Path) -> str:
  77. """上传素材文件到飞书(用于后续导入)
  78. Returns:
  79. file_token: 上传后的文件标识
  80. """
  81. file_size = file_path.stat().st_size
  82. file_name = file_path.name
  83. with open(file_path, "rb") as f:
  84. resp = httpx.post(
  85. f"{FEISHU_BASE_URL}/drive/v1/medias/upload_all",
  86. headers=_auth_headers(token),
  87. data={
  88. "file_name": file_name,
  89. "parent_type": "explorer",
  90. "parent_node": "",
  91. "size": str(file_size),
  92. },
  93. files={"file": (file_name, f, "application/octet-stream")},
  94. timeout=60,
  95. )
  96. data = resp.json()
  97. if data.get("code") != 0:
  98. raise RuntimeError(f"上传素材失败: {data.get('msg', data)}")
  99. file_token = data["data"]["file_token"]
  100. logger.info("素材上传成功: file_token=%s, size=%d", file_token, file_size)
  101. return file_token
  102. def _create_import_task(token: str, file_token: str, file_name: str) -> str:
  103. """创建导入任务(xlsx → 飞书在线表格)
  104. Returns:
  105. ticket: 导入任务标识
  106. """
  107. title = file_name.replace(".xlsx", "").replace(".xls", "")
  108. body = {
  109. "file_extension": "xlsx",
  110. "file_token": file_token,
  111. "type": "sheet",
  112. "file_name": title,
  113. "point": {
  114. "mount_type": 1,
  115. "mount_key": "",
  116. },
  117. }
  118. resp = httpx.post(
  119. f"{FEISHU_BASE_URL}/drive/v1/import_tasks",
  120. headers={**_auth_headers(token), "Content-Type": "application/json"},
  121. json=body,
  122. timeout=_HTTP_TIMEOUT,
  123. )
  124. data = resp.json()
  125. if data.get("code") != 0:
  126. raise RuntimeError(f"创建导入任务失败: {data.get('msg', data)}")
  127. ticket = data["data"]["ticket"]
  128. logger.info("导入任务已创建: ticket=%s, title=%s", ticket, title)
  129. return ticket
  130. def _wait_import_result(token: str, ticket: str) -> Dict:
  131. """轮询导入结果
  132. Returns:
  133. dict: 包含 token, type, url 的结果字典
  134. """
  135. start = time.time()
  136. while time.time() - start < _IMPORT_MAX_WAIT:
  137. resp = httpx.get(
  138. f"{FEISHU_BASE_URL}/drive/v1/import_tasks/{ticket}",
  139. headers=_auth_headers(token),
  140. timeout=_HTTP_TIMEOUT,
  141. )
  142. data = resp.json()
  143. if data.get("code") != 0:
  144. raise RuntimeError(f"查询导入结果失败: {data}")
  145. result = data.get("data", {}).get("result", {})
  146. job_status = result.get("job_status", -1)
  147. if job_status == 0:
  148. logger.info("导入成功: url=%s", result.get("url", ""))
  149. return result
  150. if job_status == 3:
  151. error_msg = result.get("job_error_msg", "未知错误")
  152. raise RuntimeError(f"导入失败: {error_msg}")
  153. time.sleep(_IMPORT_POLL_INTERVAL)
  154. raise RuntimeError(f"导入超时(等待 {_IMPORT_MAX_WAIT} 秒)")
  155. def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> None:
  156. """设置文档权限:任何人可编辑(最大权限)"""
  157. resp = httpx.patch(
  158. f"{FEISHU_BASE_URL}/drive/v1/permissions/{file_token}/public",
  159. headers={**_auth_headers(token), "Content-Type": "application/json"},
  160. params={"type": file_type},
  161. json={
  162. "external_access_entity": "open", # 外部开放
  163. "link_share_entity": "anyone_editable", # ✅ 修改:任何人可编辑
  164. },
  165. timeout=_HTTP_TIMEOUT,
  166. )
  167. data = resp.json()
  168. if data.get("code") != 0:
  169. logger.warning("设置权限失败(不影响主流程): %s", data.get("msg", data))
  170. else:
  171. logger.info("文档权限已设置: anyone_editable(任何人获得链接可编辑 - 最大权限)")
  172. def _send_link_message(chat_id: str, url: str, title: str, preamble: str = ""):
  173. """通过 IM 发送在线表格链接到群聊。
  174. - preamble 为空:走默认文案(老行为)
  175. - preamble 非空:合并"概述 + 表格链接 + 审批指引"为一条消息
  176. 会智能识别 preamble 末尾的"(追溯码: xxx)"行并保持其在最末尾
  177. 返回:成功返回 send_message 的 result 对象(含 chat_id / message_id),
  178. 失败返回 None。
  179. """
  180. try:
  181. if preamble:
  182. # 拆出末尾追溯码行(如有),URL/指引插在追溯码之前
  183. body_lines = preamble.rstrip().split("\n")
  184. traceback_line = ""
  185. if body_lines and "追溯码" in body_lines[-1]:
  186. traceback_line = body_lines.pop()
  187. # 追溯码上方可能有空行,清理掉
  188. while body_lines and body_lines[-1].strip() == "":
  189. body_lines.pop()
  190. body = "\n".join(body_lines)
  191. text = (
  192. f"{body}\n\n"
  193. f"📋 决策详单:{url}\n\n"
  194. f"审批请在聊天里直接回复我 ——\n"
  195. f"例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
  196. )
  197. if traceback_line:
  198. text += f"\n\n{traceback_line}"
  199. else:
  200. now_label = datetime.now().strftime("%m-%d %H:%M")
  201. text = (
  202. f"这是 {now_label} 这批决策的详单,方便您对照查看:\n"
  203. f"{url}\n\n"
  204. f"审批请在聊天里直接回复我 ——\n"
  205. f"例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
  206. )
  207. result = _feishu.send_message(to=chat_id, text=text)
  208. logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
  209. return result
  210. except Exception as e:
  211. logger.warning("发送报告链接失败(不影响主流程): %s", e)
  212. return None
  213. # ═══════════════════════════════════════════
  214. # 对外工具:import_to_feishu
  215. # ═══════════════════════════════════════════
  216. @tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
  217. async def import_to_feishu(
  218. ctx: ToolContext = None,
  219. xlsx_path: str = "",
  220. send_im: bool = True,
  221. chat_id: str = "",
  222. preamble: str = "",
  223. ) -> ToolResult:
  224. """将 xlsx 文件导入飞书在线表格并分享
  225. 完整流程:上传素材 → 导入为在线表格 → 设置权限 → IM 发送链接
  226. Args:
  227. xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
  228. send_im: 是否通过 IM 发送链接(默认 True)
  229. chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
  230. preamble: 合并通知文本(非空时 IM 消息 = preamble + 表格链接 + 审批指引,单条合一;
  231. 为空时走默认"链接+简短文案"模板)
  232. """
  233. try:
  234. # --- 1. 定位 xlsx 文件 ---
  235. if xlsx_path:
  236. file_path = Path(xlsx_path)
  237. else:
  238. # 自动找最新的 xlsx
  239. candidates = sorted(REPORTS_DIR.glob("*.xlsx"), reverse=True)
  240. if not candidates:
  241. return ToolResult(
  242. title="未找到报告文件",
  243. output=f"在 {REPORTS_DIR} 下未找到 xlsx 文件,请先运行 generate_report 生成报告",
  244. )
  245. file_path = candidates[0]
  246. if not file_path.exists():
  247. return ToolResult(
  248. title="文件不存在",
  249. output=f"文件不存在: {file_path}",
  250. )
  251. logger.info("开始导入飞书: %s (%d bytes)", file_path.name, file_path.stat().st_size)
  252. # --- 2. 获取 token ---
  253. token = _get_tenant_token()
  254. # --- 3. 上传素材 ---
  255. file_token = _upload_media(token, file_path)
  256. # --- 4. 创建导入任务 & 等待完成 ---
  257. ticket = _create_import_task(token, file_token, file_path.name)
  258. result = _wait_import_result(token, ticket)
  259. url = result.get("url", "")
  260. sheet_token = result.get("token", "")
  261. file_type = result.get("type", "sheet")
  262. # --- 5. 设置权限 ---
  263. if sheet_token:
  264. _set_permission(token, sheet_token, file_type)
  265. # --- 6. IM 发送链接 ---
  266. im_sent = False
  267. im_chat_id = ""
  268. im_message_id = ""
  269. if send_im and url:
  270. target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
  271. if target_chat:
  272. title = file_path.stem
  273. send_result = _send_link_message(target_chat, url, title, preamble=preamble)
  274. if send_result is not None:
  275. im_sent = True
  276. # send_message 返回值里通常带 chat_id/message_id,用于 P2P 轮询关联
  277. im_chat_id = getattr(send_result, "chat_id", "") or ""
  278. im_message_id = getattr(send_result, "message_id", "") or ""
  279. # --- 结果 ---
  280. output_lines = [
  281. f"文件: {file_path.name}",
  282. f"在线表格: {url}",
  283. f"表格 token: {sheet_token}",
  284. f"IM 发送: {'成功' if im_sent else '未发送' if not send_im else '失败'}",
  285. ]
  286. return ToolResult(
  287. title=f"飞书导入成功: {file_path.stem}",
  288. output="\n".join(output_lines),
  289. metadata={
  290. "url": url,
  291. "sheet_token": sheet_token,
  292. "file_type": file_type,
  293. "xlsx_path": str(file_path),
  294. "im_sent": im_sent,
  295. "im_chat_id": im_chat_id,
  296. "im_message_id": im_message_id,
  297. },
  298. )
  299. except Exception as e:
  300. logger.error("import_to_feishu 失败: %s", e, exc_info=True)
  301. return ToolResult(
  302. title="飞书导入失败",
  303. output=str(e),
  304. )