feishu_doc.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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) -> bool:
  173. """通过 IM 发送在线表格链接到群聊。
  174. 文案:口语化,第二人称「您」,不用【】公文头。
  175. """
  176. try:
  177. now_label = datetime.now().strftime("%m-%d %H:%M")
  178. text = (
  179. f"这是 {now_label} 这批决策的详单,方便您对照查看:\n"
  180. f"{url}\n\n"
  181. f"审批请在聊天里直接回复我 ——\n"
  182. f"例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
  183. )
  184. _feishu.send_message(to=chat_id, text=text)
  185. logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
  186. return True
  187. except Exception as e:
  188. logger.warning("发送报告链接失败(不影响主流程): %s", e)
  189. return False
  190. # ═══════════════════════════════════════════
  191. # 对外工具:import_to_feishu
  192. # ═══════════════════════════════════════════
  193. @tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
  194. async def import_to_feishu(
  195. ctx: ToolContext,
  196. xlsx_path: str = "",
  197. send_im: bool = True,
  198. chat_id: str = "",
  199. ) -> ToolResult:
  200. """将 xlsx 文件导入飞书在线表格并分享
  201. 完整流程:上传素材 → 导入为在线表格 → 设置权限 → IM 发送链接
  202. Args:
  203. xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
  204. send_im: 是否通过 IM 发送链接(默认 True)
  205. chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
  206. """
  207. try:
  208. # --- 1. 定位 xlsx 文件 ---
  209. if xlsx_path:
  210. file_path = Path(xlsx_path)
  211. else:
  212. # 自动找最新的 xlsx
  213. candidates = sorted(REPORTS_DIR.glob("*.xlsx"), reverse=True)
  214. if not candidates:
  215. return ToolResult(
  216. title="未找到报告文件",
  217. output=f"在 {REPORTS_DIR} 下未找到 xlsx 文件,请先运行 generate_report 生成报告",
  218. )
  219. file_path = candidates[0]
  220. if not file_path.exists():
  221. return ToolResult(
  222. title="文件不存在",
  223. output=f"文件不存在: {file_path}",
  224. )
  225. logger.info("开始导入飞书: %s (%d bytes)", file_path.name, file_path.stat().st_size)
  226. # --- 2. 获取 token ---
  227. token = _get_tenant_token()
  228. # --- 3. 上传素材 ---
  229. file_token = _upload_media(token, file_path)
  230. # --- 4. 创建导入任务 & 等待完成 ---
  231. ticket = _create_import_task(token, file_token, file_path.name)
  232. result = _wait_import_result(token, ticket)
  233. url = result.get("url", "")
  234. sheet_token = result.get("token", "")
  235. file_type = result.get("type", "sheet")
  236. # --- 5. 设置权限 ---
  237. if sheet_token:
  238. _set_permission(token, sheet_token, file_type)
  239. # --- 6. IM 发送链接 ---
  240. im_sent = False
  241. if send_im and url:
  242. target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
  243. if target_chat:
  244. title = file_path.stem
  245. im_sent = _send_link_message(target_chat, url, title)
  246. # --- 结果 ---
  247. output_lines = [
  248. f"文件: {file_path.name}",
  249. f"在线表格: {url}",
  250. f"表格 token: {sheet_token}",
  251. f"IM 发送: {'成功' if im_sent else '未发送' if not send_im else '失败'}",
  252. ]
  253. return ToolResult(
  254. title=f"飞书导入成功: {file_path.stem}",
  255. output="\n".join(output_lines),
  256. metadata={
  257. "url": url,
  258. "sheet_token": sheet_token,
  259. "file_type": file_type,
  260. "xlsx_path": str(file_path),
  261. "im_sent": im_sent,
  262. },
  263. )
  264. except Exception as e:
  265. logger.error("import_to_feishu 失败: %s", e, exc_info=True)
  266. return ToolResult(
  267. title="飞书导入失败",
  268. output=str(e),
  269. )