feishu_client.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. """Small environment-driven Feishu client for publishing tabular files."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import time
  6. from pathlib import Path
  7. from typing import Any
  8. import httpx
  9. SUPPORTED_EXTENSIONS = {"csv", "xls", "xlsx"}
  10. class FeishuApiError(RuntimeError):
  11. """Raised when Feishu returns a failed response without leaking credentials."""
  12. class FeishuPublisher:
  13. def __init__(
  14. self,
  15. app_id: str,
  16. app_secret: str,
  17. *,
  18. base_url: str = "https://open.feishu.cn/open-apis",
  19. timeout: float = 30.0,
  20. client: httpx.Client | None = None,
  21. ) -> None:
  22. if not app_id.strip():
  23. raise ValueError("FEISHU_APP_ID 未配置")
  24. if not app_secret.strip():
  25. raise ValueError("FEISHU_APP_SECRET 未配置")
  26. self._app_id = app_id.strip()
  27. self._app_secret = app_secret.strip()
  28. self._base_url = base_url.rstrip("/")
  29. self._owns_client = client is None
  30. self._client = client or httpx.Client(timeout=timeout)
  31. @classmethod
  32. def from_env(cls, **kwargs: Any) -> "FeishuPublisher":
  33. return cls(
  34. os.getenv("FEISHU_APP_ID", ""),
  35. os.getenv("FEISHU_APP_SECRET", ""),
  36. base_url=os.getenv(
  37. "FEISHU_BASE_URL", "https://open.feishu.cn/open-apis"
  38. ),
  39. **kwargs,
  40. )
  41. def close(self) -> None:
  42. if self._owns_client:
  43. self._client.close()
  44. def __enter__(self) -> "FeishuPublisher":
  45. return self
  46. def __exit__(self, *_: object) -> None:
  47. self.close()
  48. @staticmethod
  49. def _payload(response: httpx.Response, action: str) -> dict[str, Any]:
  50. try:
  51. response.raise_for_status()
  52. payload = response.json()
  53. except (httpx.HTTPError, ValueError) as exc:
  54. raise FeishuApiError(f"{action}失败:HTTP 响应无效") from exc
  55. if payload.get("code") != 0:
  56. code = payload.get("code", "unknown")
  57. message = payload.get("msg", "unknown error")
  58. raise FeishuApiError(f"{action}失败:code={code}, msg={message}")
  59. return payload
  60. @staticmethod
  61. def _headers(token: str) -> dict[str, str]:
  62. return {"Authorization": f"Bearer {token}"}
  63. def tenant_access_token(self) -> str:
  64. response = self._client.post(
  65. f"{self._base_url}/auth/v3/tenant_access_token/internal",
  66. json={"app_id": self._app_id, "app_secret": self._app_secret},
  67. )
  68. return str(
  69. self._payload(response, "获取 tenant access token")[
  70. "tenant_access_token"
  71. ]
  72. )
  73. def list_chats(self, token: str, name: str = "") -> list[dict[str, str]]:
  74. matches: list[dict[str, str]] = []
  75. page_token = ""
  76. while True:
  77. params: dict[str, str | int] = {"page_size": 100}
  78. if page_token:
  79. params["page_token"] = page_token
  80. response = self._client.get(
  81. f"{self._base_url}/im/v1/chats",
  82. headers=self._headers(token),
  83. params=params,
  84. )
  85. data = self._payload(response, "查询机器人可见群聊").get("data", {})
  86. for item in data.get("items", []) or []:
  87. chat_name = str(item.get("name") or "")
  88. chat_id = str(item.get("chat_id") or "")
  89. if chat_id and (not name or name in chat_name):
  90. matches.append({"name": chat_name, "chat_id": chat_id})
  91. if not data.get("has_more"):
  92. break
  93. page_token = str(data.get("page_token") or "")
  94. if not page_token:
  95. break
  96. return matches
  97. def resolve_chat_id(self, token: str, chat_name: str) -> str:
  98. exact = [
  99. chat
  100. for chat in self.list_chats(token, chat_name)
  101. if chat["name"] == chat_name
  102. ]
  103. if not exact:
  104. raise FeishuApiError(f"机器人可见群聊中未找到:{chat_name}")
  105. if len(exact) > 1:
  106. ids = ", ".join(chat["chat_id"] for chat in exact)
  107. raise FeishuApiError(
  108. f"群名匹配多个群聊:{chat_name};请改用 chat_id。候选:{ids}"
  109. )
  110. return exact[0]["chat_id"]
  111. @staticmethod
  112. def validate_file(file_path: Path) -> str:
  113. if not file_path.is_file():
  114. raise FileNotFoundError(f"结果文件不存在:{file_path}")
  115. extension = file_path.suffix.lower().lstrip(".")
  116. if extension not in SUPPORTED_EXTENSIONS:
  117. allowed = ", ".join(sorted(SUPPORTED_EXTENSIONS))
  118. raise ValueError(f"不支持的文件类型:.{extension};仅支持 {allowed}")
  119. if file_path.stat().st_size == 0:
  120. raise ValueError(f"结果文件为空:{file_path}")
  121. return extension
  122. def upload_import_source(
  123. self, token: str, file_path: Path, extension: str
  124. ) -> str:
  125. extra = json.dumps(
  126. {"obj_type": "sheet", "file_extension": extension},
  127. ensure_ascii=False,
  128. )
  129. with file_path.open("rb") as handle:
  130. response = self._client.post(
  131. f"{self._base_url}/drive/v1/medias/upload_all",
  132. headers=self._headers(token),
  133. data={
  134. "file_name": file_path.name,
  135. "parent_type": "ccm_import_open",
  136. "size": str(file_path.stat().st_size),
  137. "extra": extra,
  138. },
  139. files={
  140. "file": (
  141. file_path.name,
  142. handle,
  143. "application/octet-stream",
  144. )
  145. },
  146. )
  147. return str(
  148. self._payload(response, "上传待导入文件")["data"]["file_token"]
  149. )
  150. def create_import_task(
  151. self,
  152. token: str,
  153. file_token: str,
  154. *,
  155. extension: str,
  156. title: str,
  157. ) -> str:
  158. response = self._client.post(
  159. f"{self._base_url}/drive/v1/import_tasks",
  160. headers={**self._headers(token), "Content-Type": "application/json"},
  161. json={
  162. "file_extension": extension,
  163. "file_token": file_token,
  164. "type": "sheet",
  165. "file_name": title,
  166. "point": {"mount_type": 1, "mount_key": ""},
  167. },
  168. )
  169. return str(self._payload(response, "创建表格导入任务")["data"]["ticket"])
  170. def wait_import_result(
  171. self,
  172. token: str,
  173. ticket: str,
  174. *,
  175. max_wait: float = 90.0,
  176. poll_interval: float = 2.0,
  177. ) -> dict[str, Any]:
  178. deadline = time.monotonic() + max_wait
  179. while time.monotonic() < deadline:
  180. response = self._client.get(
  181. f"{self._base_url}/drive/v1/import_tasks/{ticket}",
  182. headers=self._headers(token),
  183. )
  184. result = (
  185. self._payload(response, "查询表格导入结果")
  186. .get("data", {})
  187. .get("result", {})
  188. )
  189. status = result.get("job_status")
  190. if status == 0:
  191. return result
  192. if status == 3:
  193. message = result.get("job_error_msg", "unknown error")
  194. raise FeishuApiError(f"导入在线表格失败:{message}")
  195. time.sleep(poll_interval)
  196. raise TimeoutError(f"导入在线表格超过 {max_wait:g} 秒仍未完成")
  197. def set_anyone_editable(
  198. self, token: str, sheet_token: str, file_type: str = "sheet"
  199. ) -> None:
  200. response = self._client.patch(
  201. f"{self._base_url}/drive/v1/permissions/{sheet_token}/public",
  202. headers={**self._headers(token), "Content-Type": "application/json"},
  203. params={"type": file_type},
  204. json={
  205. "external_access_entity": "open",
  206. "link_share_entity": "anyone_editable",
  207. },
  208. )
  209. self._payload(response, "设置任何人可编辑权限")
  210. def send_sheet_card(
  211. self,
  212. token: str,
  213. *,
  214. chat_id: str,
  215. title: str,
  216. message: str,
  217. url: str,
  218. ) -> str:
  219. card = {
  220. "config": {"wide_screen_mode": True},
  221. "header": {
  222. "template": "blue",
  223. "title": {"tag": "plain_text", "content": title},
  224. },
  225. "elements": [
  226. {
  227. "tag": "div",
  228. "text": {"tag": "lark_md", "content": message},
  229. },
  230. {"tag": "hr"},
  231. {
  232. "tag": "action",
  233. "actions": [
  234. {
  235. "tag": "button",
  236. "type": "primary",
  237. "text": {
  238. "tag": "plain_text",
  239. "content": "打开在线表格",
  240. },
  241. "url": url,
  242. }
  243. ],
  244. },
  245. ],
  246. }
  247. response = self._client.post(
  248. f"{self._base_url}/im/v1/messages",
  249. headers={**self._headers(token), "Content-Type": "application/json"},
  250. params={"receive_id_type": "chat_id"},
  251. json={
  252. "receive_id": chat_id,
  253. "msg_type": "interactive",
  254. "content": json.dumps(card, ensure_ascii=False),
  255. },
  256. )
  257. return str(self._payload(response, "发送飞书群卡片")["data"]["message_id"])
  258. def publish(
  259. self,
  260. file_path: Path,
  261. *,
  262. title: str,
  263. message: str,
  264. chat_id: str = "",
  265. chat_name: str = "",
  266. notify: bool = True,
  267. ) -> dict[str, Any]:
  268. extension = self.validate_file(file_path)
  269. token = self.tenant_access_token()
  270. resolved_chat_id = chat_id.strip()
  271. if notify and not resolved_chat_id:
  272. if not chat_name.strip():
  273. raise ValueError(
  274. "未配置目标群;请设置 FEISHU_TARGET_CHAT_ID、"
  275. "FEISHU_TARGET_CHAT_NAME 或命令行参数"
  276. )
  277. resolved_chat_id = self.resolve_chat_id(token, chat_name.strip())
  278. file_token = self.upload_import_source(token, file_path, extension)
  279. ticket = self.create_import_task(
  280. token,
  281. file_token,
  282. extension=extension,
  283. title=title,
  284. )
  285. imported = self.wait_import_result(token, ticket)
  286. url = str(imported.get("url") or "")
  287. sheet_token = str(imported.get("token") or "")
  288. file_type = str(imported.get("type") or "sheet")
  289. if not url or not sheet_token:
  290. raise FeishuApiError("导入成功响应缺少在线表格 URL 或 token")
  291. self.set_anyone_editable(token, sheet_token, file_type)
  292. message_id = ""
  293. if notify:
  294. message_id = self.send_sheet_card(
  295. token,
  296. chat_id=resolved_chat_id,
  297. title=title,
  298. message=message,
  299. url=url,
  300. )
  301. return {
  302. "url": url,
  303. "permission": "anyone_editable",
  304. "message_sent": bool(message_id),
  305. "chat_id": resolved_chat_id if notify else "",
  306. "message_id": message_id,
  307. }