feishu_doc.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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 pathlib import Path
  20. from typing import Dict, Optional
  21. import httpx
  22. from agent.tools import tool
  23. from agent.tools.models import ToolContext, ToolResult
  24. from agent.tools.builtin.feishu.feishu_client import FeishuClient
  25. _MINI_DIR = Path(__file__).resolve().parent.parent
  26. _TOOLS_DIR = Path(__file__).resolve().parent
  27. if str(_MINI_DIR) not in sys.path:
  28. sys.path.insert(0, str(_MINI_DIR))
  29. if str(_TOOLS_DIR) not in sys.path:
  30. sys.path.insert(0, str(_TOOLS_DIR))
  31. from config import (
  32. FEISHU_APP_ID,
  33. FEISHU_APP_SECRET,
  34. FEISHU_OPERATOR_CHAT_ID,
  35. REPORTS_DIR,
  36. )
  37. logger = logging.getLogger(__name__)
  38. # ═══════════════════════════════════════════
  39. # 常量
  40. # ═══════════════════════════════════════════
  41. FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
  42. _HTTP_TIMEOUT = 30
  43. _IMPORT_POLL_INTERVAL = 2 # 秒
  44. _IMPORT_MAX_WAIT = 60 # 秒
  45. # 全局 FeishuClient(复用框架能力发 IM 消息)
  46. _feishu = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
  47. # token 缓存
  48. _token_cache: Dict[str, object] = {}
  49. _TOKEN_TTL = 1500 # 25 分钟(官方有效期 2 小时,留余量)
  50. # ═══════════════════════════════════════════
  51. # 内部方法:飞书 tenant_access_token
  52. # ═══════════════════════════════════════════
  53. def _get_tenant_token() -> str:
  54. """获取飞书 tenant_access_token(带缓存)"""
  55. cached = _token_cache.get("tenant")
  56. if cached and time.time() - cached["ts"] < _TOKEN_TTL:
  57. return cached["token"]
  58. resp = httpx.post(
  59. f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
  60. json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
  61. timeout=_HTTP_TIMEOUT,
  62. )
  63. data = resp.json()
  64. if data.get("code") != 0:
  65. raise RuntimeError(f"获取飞书 token 失败: {data}")
  66. token = data["tenant_access_token"]
  67. _token_cache["tenant"] = {"token": token, "ts": time.time()}
  68. logger.info("飞书 tenant_access_token 已刷新")
  69. return token
  70. def _auth_headers(token: str) -> Dict[str, str]:
  71. return {"Authorization": f"Bearer {token}"}
  72. # ═══════════════════════════════════════════
  73. # 内部方法:Drive API
  74. # ═══════════════════════════════════════════
  75. def _upload_media(token: str, file_path: Path) -> str:
  76. """上传素材文件到飞书(用于后续导入)
  77. Returns:
  78. file_token: 上传后的文件标识
  79. """
  80. file_size = file_path.stat().st_size
  81. file_name = file_path.name
  82. with open(file_path, "rb") as f:
  83. resp = httpx.post(
  84. f"{FEISHU_BASE_URL}/drive/v1/medias/upload_all",
  85. headers=_auth_headers(token),
  86. data={
  87. "file_name": file_name,
  88. "parent_type": "explorer",
  89. "parent_node": "",
  90. "size": str(file_size),
  91. },
  92. files={"file": (file_name, f, "application/octet-stream")},
  93. timeout=60,
  94. )
  95. data = resp.json()
  96. if data.get("code") != 0:
  97. raise RuntimeError(f"上传素材失败: {data.get('msg', data)}")
  98. file_token = data["data"]["file_token"]
  99. logger.info("素材上传成功: file_token=%s, size=%d", file_token, file_size)
  100. return file_token
  101. def _create_import_task(token: str, file_token: str, file_name: str) -> str:
  102. """创建导入任务(xlsx → 飞书在线表格)
  103. Returns:
  104. ticket: 导入任务标识
  105. """
  106. title = file_name.replace(".xlsx", "").replace(".xls", "")
  107. body = {
  108. "file_extension": "xlsx",
  109. "file_token": file_token,
  110. "type": "sheet",
  111. "file_name": title,
  112. "point": {
  113. "mount_type": 1,
  114. "mount_key": "",
  115. },
  116. }
  117. resp = httpx.post(
  118. f"{FEISHU_BASE_URL}/drive/v1/import_tasks",
  119. headers={**_auth_headers(token), "Content-Type": "application/json"},
  120. json=body,
  121. timeout=_HTTP_TIMEOUT,
  122. )
  123. data = resp.json()
  124. if data.get("code") != 0:
  125. raise RuntimeError(f"创建导入任务失败: {data.get('msg', data)}")
  126. ticket = data["data"]["ticket"]
  127. logger.info("导入任务已创建: ticket=%s, title=%s", ticket, title)
  128. return ticket
  129. def _wait_import_result(token: str, ticket: str) -> Dict:
  130. """轮询导入结果
  131. Returns:
  132. dict: 包含 token, type, url 的结果字典
  133. """
  134. start = time.time()
  135. while time.time() - start < _IMPORT_MAX_WAIT:
  136. resp = httpx.get(
  137. f"{FEISHU_BASE_URL}/drive/v1/import_tasks/{ticket}",
  138. headers=_auth_headers(token),
  139. timeout=_HTTP_TIMEOUT,
  140. )
  141. data = resp.json()
  142. if data.get("code") != 0:
  143. raise RuntimeError(f"查询导入结果失败: {data}")
  144. result = data.get("data", {}).get("result", {})
  145. job_status = result.get("job_status", -1)
  146. if job_status == 0:
  147. logger.info("导入成功: url=%s", result.get("url", ""))
  148. return result
  149. if job_status == 3:
  150. error_msg = result.get("job_error_msg", "未知错误")
  151. raise RuntimeError(f"导入失败: {error_msg}")
  152. time.sleep(_IMPORT_POLL_INTERVAL)
  153. raise RuntimeError(f"导入超时(等待 {_IMPORT_MAX_WAIT} 秒)")
  154. def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> None:
  155. """设置文档权限:任何人可编辑(最大权限)"""
  156. resp = httpx.patch(
  157. f"{FEISHU_BASE_URL}/drive/v1/permissions/{file_token}/public",
  158. headers={**_auth_headers(token), "Content-Type": "application/json"},
  159. params={"type": file_type},
  160. json={
  161. "external_access_entity": "open", # 外部开放
  162. "link_share_entity": "anyone_editable", # ✅ 修改:任何人可编辑
  163. },
  164. timeout=_HTTP_TIMEOUT,
  165. )
  166. data = resp.json()
  167. if data.get("code") != 0:
  168. logger.warning("设置权限失败(不影响主流程): %s", data.get("msg", data))
  169. else:
  170. logger.info("文档权限已设置: anyone_editable(任何人获得链接可编辑 - 最大权限)")
  171. def _send_link_message(chat_id: str, url: str, title: str) -> bool:
  172. """通过 IM 发送在线表格链接到群聊"""
  173. try:
  174. text = f"**广告决策报告: {title}**\n\n报告已生成,点击查看: [打开在线表格]({url})"
  175. _feishu.send_message(to=chat_id, text=text)
  176. logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
  177. return True
  178. except Exception as e:
  179. logger.warning("发送报告链接失败(不影响主流程): %s", e)
  180. return False
  181. # ═══════════════════════════════════════════
  182. # 对外工具:import_to_feishu
  183. # ═══════════════════════════════════════════
  184. @tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
  185. async def import_to_feishu(
  186. ctx: ToolContext,
  187. xlsx_path: str = "",
  188. send_im: bool = True,
  189. chat_id: str = "",
  190. ) -> ToolResult:
  191. """将 xlsx 文件导入飞书在线表格并分享
  192. 完整流程:上传素材 → 导入为在线表格 → 设置权限 → IM 发送链接
  193. Args:
  194. xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
  195. send_im: 是否通过 IM 发送链接(默认 True)
  196. chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
  197. """
  198. try:
  199. # --- 1. 定位 xlsx 文件 ---
  200. if xlsx_path:
  201. file_path = Path(xlsx_path)
  202. else:
  203. # 自动找最新的 xlsx
  204. candidates = sorted(REPORTS_DIR.glob("*.xlsx"), reverse=True)
  205. if not candidates:
  206. return ToolResult(
  207. title="未找到报告文件",
  208. output=f"在 {REPORTS_DIR} 下未找到 xlsx 文件,请先运行 generate_report 生成报告",
  209. )
  210. file_path = candidates[0]
  211. if not file_path.exists():
  212. return ToolResult(
  213. title="文件不存在",
  214. output=f"文件不存在: {file_path}",
  215. )
  216. logger.info("开始导入飞书: %s (%d bytes)", file_path.name, file_path.stat().st_size)
  217. # --- 2. 获取 token ---
  218. token = _get_tenant_token()
  219. # --- 3. 上传素材 ---
  220. file_token = _upload_media(token, file_path)
  221. # --- 4. 创建导入任务 & 等待完成 ---
  222. ticket = _create_import_task(token, file_token, file_path.name)
  223. result = _wait_import_result(token, ticket)
  224. url = result.get("url", "")
  225. sheet_token = result.get("token", "")
  226. file_type = result.get("type", "sheet")
  227. # --- 5. 设置权限 ---
  228. if sheet_token:
  229. _set_permission(token, sheet_token, file_type)
  230. # --- 6. IM 发送链接 ---
  231. im_sent = False
  232. if send_im and url:
  233. target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
  234. if target_chat:
  235. title = file_path.stem
  236. im_sent = _send_link_message(target_chat, url, title)
  237. # --- 结果 ---
  238. output_lines = [
  239. f"文件: {file_path.name}",
  240. f"在线表格: {url}",
  241. f"表格 token: {sheet_token}",
  242. f"IM 发送: {'成功' if im_sent else '未发送' if not send_im else '失败'}",
  243. ]
  244. return ToolResult(
  245. title=f"飞书导入成功: {file_path.stem}",
  246. output="\n".join(output_lines),
  247. metadata={
  248. "url": url,
  249. "sheet_token": sheet_token,
  250. "file_type": file_type,
  251. "xlsx_path": str(file_path),
  252. "im_sent": im_sent,
  253. },
  254. )
  255. except Exception as e:
  256. logger.error("import_to_feishu 失败: %s", e, exc_info=True)
  257. return ToolResult(
  258. title="飞书导入失败",
  259. output=str(e),
  260. )