feishu_doc.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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 _build_link_card(url: str, preamble: str = "") -> Dict:
  173. """构造 interactive 消息卡片 JSON(2026-06-09 升级:解决 unfurling 不可靠)。
  174. - 卡片 header:固定标题 "📋 决策审批"
  175. - div 元素:preamble 全文(lark_md 富文本渲染),保持原有"追溯码末尾"逻辑
  176. - hr 分隔线
  177. - action 按钮:跳转 sheet
  178. 用户的 unfurling 退化问题(2026-06-09):同代码同消息,飞书 unfurling 自动展开行为
  179. 在 text 消息上不可靠;改卡片消息,客户端**强制**渲染我们定义的元素,不依赖 unfurling。
  180. """
  181. if preamble:
  182. # 拆末尾追溯码行(如有),保持原有处理
  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. while body_lines and body_lines[-1].strip() == "":
  188. body_lines.pop()
  189. body = "\n".join(body_lines)
  190. md_lines = [body, "", "审批请在聊天里直接回复我 ——", "例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"]
  191. if traceback_line:
  192. md_lines.extend(["", traceback_line])
  193. md_content = "\n".join(md_lines)
  194. else:
  195. now_label = datetime.now().strftime("%m-%d %H:%M")
  196. md_content = (
  197. f"这是 {now_label} 这批决策的详单。\n\n"
  198. "审批请在聊天里直接回复我 ——\n"
  199. "例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
  200. )
  201. return {
  202. "config": {"wide_screen_mode": True},
  203. "header": {
  204. "title": {"tag": "plain_text", "content": "📋 决策审批"},
  205. "template": "blue",
  206. },
  207. "elements": [
  208. {
  209. "tag": "div",
  210. "text": {"tag": "lark_md", "content": md_content},
  211. },
  212. {"tag": "hr"},
  213. {
  214. "tag": "action",
  215. "actions": [
  216. {
  217. "tag": "button",
  218. "text": {"tag": "plain_text", "content": "📊 打开审批表"},
  219. "type": "primary",
  220. "url": url,
  221. }
  222. ],
  223. },
  224. ],
  225. }
  226. def _send_link_message(chat_id: str, url: str, title: str, preamble: str = ""):
  227. """通过 IM 发送在线表格链接到群聊(interactive 卡片版,2026-06-09 升级)。
  228. - 走飞书 interactive 卡片消息(msg_type=interactive),不依赖 text 消息的 unfurling
  229. - 卡片含:header 标题 + preamble 富文本 + 跳转按钮
  230. - 保持原 signature 兼容:title 参数当前不进卡片(因为 header 用固定"决策审批"),
  231. 留作未来扩展(如想让调控/创意两个子系统用不同 header 标题)
  232. 返回:成功返回 send_card 的 SendResult,失败返回 None。
  233. """
  234. try:
  235. card = _build_link_card(url=url, preamble=preamble)
  236. result = _feishu.send_card(to=chat_id, card=card)
  237. logger.info("决策审批卡片已发送到群聊: chat_id=%s", chat_id)
  238. return result
  239. except Exception as e:
  240. logger.warning("发送决策审批卡片失败(不影响主流程): %s", e)
  241. return None
  242. # ═══════════════════════════════════════════
  243. # 对外工具:import_to_feishu
  244. # ═══════════════════════════════════════════
  245. @tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
  246. async def import_to_feishu(
  247. ctx: ToolContext = None,
  248. xlsx_path: str = "",
  249. send_im: bool = True,
  250. chat_id: str = "",
  251. preamble: str = "",
  252. ) -> ToolResult:
  253. """将 xlsx 文件导入飞书在线表格并分享
  254. 完整流程:上传素材 → 导入为在线表格 → 设置权限 → IM 发送链接
  255. Args:
  256. xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
  257. send_im: 是否通过 IM 发送链接(默认 True)
  258. chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
  259. preamble: 合并通知文本(非空时 IM 消息 = preamble + 表格链接 + 审批指引,单条合一;
  260. 为空时走默认"链接+简短文案"模板)
  261. """
  262. try:
  263. # --- 1. 定位 xlsx 文件 ---
  264. if xlsx_path:
  265. file_path = Path(xlsx_path)
  266. else:
  267. # 自动找最新的 xlsx
  268. candidates = sorted(REPORTS_DIR.glob("*.xlsx"), reverse=True)
  269. if not candidates:
  270. return ToolResult(
  271. title="未找到报告文件",
  272. output=f"在 {REPORTS_DIR} 下未找到 xlsx 文件,请先运行 generate_report 生成报告",
  273. )
  274. file_path = candidates[0]
  275. if not file_path.exists():
  276. return ToolResult(
  277. title="文件不存在",
  278. output=f"文件不存在: {file_path}",
  279. )
  280. logger.info("开始导入飞书: %s (%d bytes)", file_path.name, file_path.stat().st_size)
  281. # --- 2. 获取 token ---
  282. token = _get_tenant_token()
  283. # --- 3. 上传素材 ---
  284. file_token = _upload_media(token, file_path)
  285. # --- 4. 创建导入任务 & 等待完成 ---
  286. ticket = _create_import_task(token, file_token, file_path.name)
  287. result = _wait_import_result(token, ticket)
  288. url = result.get("url", "")
  289. sheet_token = result.get("token", "")
  290. file_type = result.get("type", "sheet")
  291. # --- 5. 设置权限 ---
  292. if sheet_token:
  293. _set_permission(token, sheet_token, file_type)
  294. # --- 6. IM 发送链接 ---
  295. im_sent = False
  296. im_chat_id = ""
  297. im_message_id = ""
  298. if send_im and url:
  299. target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
  300. if target_chat:
  301. title = file_path.stem
  302. send_result = _send_link_message(target_chat, url, title, preamble=preamble)
  303. if send_result is not None:
  304. im_sent = True
  305. # send_message 返回值里通常带 chat_id/message_id,用于 P2P 轮询关联
  306. im_chat_id = getattr(send_result, "chat_id", "") or ""
  307. im_message_id = getattr(send_result, "message_id", "") or ""
  308. # --- 结果 ---
  309. output_lines = [
  310. f"文件: {file_path.name}",
  311. f"在线表格: {url}",
  312. f"表格 token: {sheet_token}",
  313. f"IM 发送: {'成功' if im_sent else '未发送' if not send_im else '失败'}",
  314. ]
  315. return ToolResult(
  316. title=f"飞书导入成功: {file_path.stem}",
  317. output="\n".join(output_lines),
  318. metadata={
  319. "url": url,
  320. "sheet_token": sheet_token,
  321. "file_type": file_type,
  322. "xlsx_path": str(file_path),
  323. "im_sent": im_sent,
  324. "im_chat_id": im_chat_id,
  325. "im_message_id": im_message_id,
  326. },
  327. )
  328. except Exception as e:
  329. logger.error("import_to_feishu 失败: %s", e, exc_info=True)
  330. return ToolResult(
  331. title="飞书导入失败",
  332. output=str(e),
  333. )