feishu.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. import logging
  5. import threading
  6. import time
  7. from pathlib import Path
  8. from typing import Any, Callable
  9. import httpx
  10. import lark_oapi as lark
  11. from .config import Settings
  12. from .models import IncomingMessage
  13. logger = logging.getLogger(__name__)
  14. class FeishuApiError(RuntimeError):
  15. pass
  16. class FeishuApi:
  17. def __init__(self, settings: Settings) -> None:
  18. self.app_id = settings.feishu_app_id
  19. self.app_secret = settings.feishu_app_secret
  20. self.base_url = settings.feishu_domain + "/open-apis"
  21. self._client = httpx.AsyncClient(timeout=30)
  22. self._token = ""
  23. self._token_expires_at = 0.0
  24. self._token_lock = asyncio.Lock()
  25. @staticmethod
  26. def _payload(response: httpx.Response, action: str) -> dict[str, Any]:
  27. try:
  28. response.raise_for_status()
  29. payload = response.json()
  30. except (httpx.HTTPError, ValueError) as exc:
  31. raise FeishuApiError(f"{action}失败:HTTP 响应无效") from exc
  32. if int(payload.get("code") or 0) != 0:
  33. raise FeishuApiError(f"{action}失败:code={payload.get('code')}, msg={payload.get('msg')}")
  34. return payload
  35. async def token(self) -> str:
  36. if self._token and time.monotonic() < self._token_expires_at:
  37. return self._token
  38. async with self._token_lock:
  39. if self._token and time.monotonic() < self._token_expires_at:
  40. return self._token
  41. response = await self._client.post(
  42. f"{self.base_url}/auth/v3/tenant_access_token/internal",
  43. json={"app_id": self.app_id, "app_secret": self.app_secret},
  44. )
  45. payload = self._payload(response, "获取 tenant access token")
  46. self._token = str(payload["tenant_access_token"])
  47. self._token_expires_at = time.monotonic() + max(60, int(payload.get("expire") or 7200) - 300)
  48. return self._token
  49. async def _headers(self) -> dict[str, str]:
  50. return {"Authorization": f"Bearer {await self.token()}"}
  51. async def bot_open_id(self) -> str:
  52. response = await self._client.get(f"{self.base_url}/bot/v3/info", headers=await self._headers())
  53. payload = self._payload(response, "获取机器人信息")
  54. open_id = str((payload.get("bot") or {}).get("open_id") or "")
  55. if not open_id:
  56. raise FeishuApiError("机器人信息缺少 open_id")
  57. return open_id
  58. async def reply_text(self, message_id: str, text: str) -> None:
  59. response = await self._client.post(
  60. f"{self.base_url}/im/v1/messages/{message_id}/reply",
  61. headers={**await self._headers(), "Content-Type": "application/json"},
  62. json={"msg_type": "text", "content": json.dumps({"text": text}, ensure_ascii=False)},
  63. )
  64. self._payload(response, "回复飞书消息")
  65. async def reply_card(self, message_id: str, title: str, markdown: str, url: str | None = None) -> None:
  66. elements: list[dict[str, Any]] = [{"tag": "markdown", "content": markdown}]
  67. if url:
  68. elements.extend([
  69. {"tag": "hr"},
  70. {"tag": "action", "actions": [{
  71. "tag": "button",
  72. "type": "primary",
  73. "text": {"tag": "plain_text", "content": "打开在线表格"},
  74. "url": url,
  75. }]},
  76. ])
  77. card = {
  78. "config": {"wide_screen_mode": True},
  79. "header": {"template": "blue", "title": {"tag": "plain_text", "content": title[:80]}},
  80. "elements": elements,
  81. }
  82. response = await self._client.post(
  83. f"{self.base_url}/im/v1/messages/{message_id}/reply",
  84. headers={**await self._headers(), "Content-Type": "application/json"},
  85. json={"msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False)},
  86. )
  87. self._payload(response, "回复飞书卡片")
  88. async def publish_sheet(self, file_path: Path, title: str) -> str:
  89. extension = file_path.suffix.lower().lstrip(".")
  90. if extension not in {"csv", "xls", "xlsx"} or not file_path.is_file():
  91. raise ValueError("待发布文件必须是存在的 CSV/XLS/XLSX")
  92. headers = await self._headers()
  93. with file_path.open("rb") as handle:
  94. response = await self._client.post(
  95. f"{self.base_url}/drive/v1/medias/upload_all",
  96. headers=headers,
  97. data={
  98. "file_name": file_path.name,
  99. "parent_type": "ccm_import_open",
  100. "size": str(file_path.stat().st_size),
  101. "extra": json.dumps({"obj_type": "sheet", "file_extension": extension}),
  102. },
  103. files={"file": (file_path.name, handle, "application/octet-stream")},
  104. )
  105. file_token = str(self._payload(response, "上传待导入文件")["data"]["file_token"])
  106. response = await self._client.post(
  107. f"{self.base_url}/drive/v1/import_tasks",
  108. headers={**headers, "Content-Type": "application/json"},
  109. json={
  110. "file_extension": extension,
  111. "file_token": file_token,
  112. "type": "sheet",
  113. "file_name": title[:100],
  114. "point": {"mount_type": 1, "mount_key": ""},
  115. },
  116. )
  117. ticket = str(self._payload(response, "创建表格导入任务")["data"]["ticket"])
  118. result: dict[str, Any] | None = None
  119. for _ in range(45):
  120. await asyncio.sleep(2)
  121. response = await self._client.get(
  122. f"{self.base_url}/drive/v1/import_tasks/{ticket}", headers=headers
  123. )
  124. current = self._payload(response, "查询表格导入结果").get("data", {}).get("result", {})
  125. if current.get("job_status") == 0:
  126. result = current
  127. break
  128. if current.get("job_status") == 3:
  129. raise FeishuApiError(f"导入在线表格失败:{current.get('job_error_msg', 'unknown error')}")
  130. if result is None:
  131. raise TimeoutError("导入在线表格超过 90 秒仍未完成")
  132. sheet_token = str(result.get("token") or "")
  133. url = str(result.get("url") or "")
  134. file_type = str(result.get("type") or "sheet")
  135. if not sheet_token or not url:
  136. raise FeishuApiError("导入响应缺少表格 token 或 URL")
  137. response = await self._client.patch(
  138. f"{self.base_url}/drive/v2/permissions/{sheet_token}/public",
  139. headers={**headers, "Content-Type": "application/json"},
  140. params={"type": file_type},
  141. json={"external_access": False, "link_share_entity": "tenant_readable"},
  142. )
  143. self._payload(response, "设置企业内链接可读权限")
  144. return url
  145. async def close(self) -> None:
  146. await self._client.aclose()
  147. class FeishuLongConnection:
  148. def __init__(self, settings: Settings, bot_open_id: str, callback: Callable[[IncomingMessage], None]) -> None:
  149. self.settings = settings
  150. self.bot_open_id = bot_open_id
  151. self.callback = callback
  152. self._thread: threading.Thread | None = None
  153. self._client: Any = None
  154. @staticmethod
  155. def _parse_content(content: str, message_type: str) -> str:
  156. try:
  157. payload = json.loads(content)
  158. except (TypeError, json.JSONDecodeError):
  159. return str(content)
  160. if message_type == "text":
  161. return str(payload.get("text") or "")
  162. if message_type == "post":
  163. parts: list[str] = []
  164. body = payload.get("zh_cn") or payload
  165. if body.get("title"):
  166. parts.append(str(body["title"]))
  167. for paragraph in body.get("content", []) or []:
  168. for item in paragraph if isinstance(paragraph, list) else []:
  169. if item.get("tag") in {"text", "a"}:
  170. parts.append(str(item.get("text") or item.get("href") or ""))
  171. return "\n".join(parts).strip()
  172. return ""
  173. def _handle(self, data: Any) -> None:
  174. try:
  175. event = data.event
  176. message = event.message
  177. sender_id = event.sender.sender_id if event.sender else None
  178. mentions = list(message.mentions or [])
  179. mentioned_bot = any(
  180. getattr(getattr(mention, "id", None), "open_id", None) == self.bot_open_id
  181. for mention in mentions
  182. )
  183. text = self._parse_content(message.content, message.message_type)
  184. for mention in mentions:
  185. key = str(getattr(mention, "key", "") or "")
  186. name = str(getattr(mention, "name", "") or "")
  187. if key:
  188. text = text.replace(key, "")
  189. if name:
  190. text = text.replace(f"@{name}", "")
  191. incoming = IncomingMessage(
  192. message_id=str(message.message_id or ""),
  193. chat_id=str(message.chat_id or ""),
  194. chat_type=str(message.chat_type or ""),
  195. sender_open_id=str(getattr(sender_id, "open_id", "") or ""),
  196. text=text.strip(),
  197. message_type=str(message.message_type or ""),
  198. mentioned_bot=mentioned_bot,
  199. )
  200. self.callback(incoming)
  201. except Exception:
  202. logger.exception("Failed to parse Feishu event")
  203. def start(self) -> threading.Thread:
  204. dispatcher = (
  205. lark.EventDispatcherHandler.builder("", "")
  206. .register_p2_im_message_receive_v1(self._handle)
  207. .build()
  208. )
  209. domain = lark.LARK_DOMAIN if "larksuite" in self.settings.feishu_domain else lark.FEISHU_DOMAIN
  210. self._client = lark.ws.Client(
  211. self.settings.feishu_app_id,
  212. self.settings.feishu_app_secret,
  213. event_handler=dispatcher,
  214. domain=domain,
  215. # INFO includes the full WebSocket URL and its ephemeral access key.
  216. log_level=lark.LogLevel.CRITICAL,
  217. )
  218. self._thread = threading.Thread(target=self._client.start, name="feishu-websocket", daemon=True)
  219. self._thread.start()
  220. logger.info("Feishu WebSocket thread started")
  221. return self._thread