| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- """Small environment-driven Feishu client for publishing tabular files."""
- from __future__ import annotations
- import json
- import os
- import time
- from pathlib import Path
- from typing import Any
- import httpx
- SUPPORTED_EXTENSIONS = {"csv", "xls", "xlsx"}
- class FeishuApiError(RuntimeError):
- """Raised when Feishu returns a failed response without leaking credentials."""
- class FeishuPublisher:
- def __init__(
- self,
- app_id: str,
- app_secret: str,
- *,
- base_url: str = "https://open.feishu.cn/open-apis",
- timeout: float = 30.0,
- client: httpx.Client | None = None,
- ) -> None:
- if not app_id.strip():
- raise ValueError("FEISHU_APP_ID 未配置")
- if not app_secret.strip():
- raise ValueError("FEISHU_APP_SECRET 未配置")
- self._app_id = app_id.strip()
- self._app_secret = app_secret.strip()
- self._base_url = base_url.rstrip("/")
- self._owns_client = client is None
- self._client = client or httpx.Client(timeout=timeout)
- @classmethod
- def from_env(cls, **kwargs: Any) -> "FeishuPublisher":
- return cls(
- os.getenv("FEISHU_APP_ID", ""),
- os.getenv("FEISHU_APP_SECRET", ""),
- base_url=os.getenv(
- "FEISHU_BASE_URL", "https://open.feishu.cn/open-apis"
- ),
- **kwargs,
- )
- def close(self) -> None:
- if self._owns_client:
- self._client.close()
- def __enter__(self) -> "FeishuPublisher":
- return self
- def __exit__(self, *_: object) -> None:
- self.close()
- @staticmethod
- def _payload(response: httpx.Response, action: str) -> dict[str, Any]:
- try:
- response.raise_for_status()
- payload = response.json()
- except (httpx.HTTPError, ValueError) as exc:
- raise FeishuApiError(f"{action}失败:HTTP 响应无效") from exc
- if payload.get("code") != 0:
- code = payload.get("code", "unknown")
- message = payload.get("msg", "unknown error")
- raise FeishuApiError(f"{action}失败:code={code}, msg={message}")
- return payload
- @staticmethod
- def _headers(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
- def tenant_access_token(self) -> str:
- response = self._client.post(
- f"{self._base_url}/auth/v3/tenant_access_token/internal",
- json={"app_id": self._app_id, "app_secret": self._app_secret},
- )
- return str(
- self._payload(response, "获取 tenant access token")[
- "tenant_access_token"
- ]
- )
- def list_chats(self, token: str, name: str = "") -> list[dict[str, str]]:
- matches: list[dict[str, str]] = []
- page_token = ""
- while True:
- params: dict[str, str | int] = {"page_size": 100}
- if page_token:
- params["page_token"] = page_token
- response = self._client.get(
- f"{self._base_url}/im/v1/chats",
- headers=self._headers(token),
- params=params,
- )
- data = self._payload(response, "查询机器人可见群聊").get("data", {})
- for item in data.get("items", []) or []:
- chat_name = str(item.get("name") or "")
- chat_id = str(item.get("chat_id") or "")
- if chat_id and (not name or name in chat_name):
- matches.append({"name": chat_name, "chat_id": chat_id})
- if not data.get("has_more"):
- break
- page_token = str(data.get("page_token") or "")
- if not page_token:
- break
- return matches
- def resolve_chat_id(self, token: str, chat_name: str) -> str:
- exact = [
- chat
- for chat in self.list_chats(token, chat_name)
- if chat["name"] == chat_name
- ]
- if not exact:
- raise FeishuApiError(f"机器人可见群聊中未找到:{chat_name}")
- if len(exact) > 1:
- ids = ", ".join(chat["chat_id"] for chat in exact)
- raise FeishuApiError(
- f"群名匹配多个群聊:{chat_name};请改用 chat_id。候选:{ids}"
- )
- return exact[0]["chat_id"]
- @staticmethod
- def validate_file(file_path: Path) -> str:
- if not file_path.is_file():
- raise FileNotFoundError(f"结果文件不存在:{file_path}")
- extension = file_path.suffix.lower().lstrip(".")
- if extension not in SUPPORTED_EXTENSIONS:
- allowed = ", ".join(sorted(SUPPORTED_EXTENSIONS))
- raise ValueError(f"不支持的文件类型:.{extension};仅支持 {allowed}")
- if file_path.stat().st_size == 0:
- raise ValueError(f"结果文件为空:{file_path}")
- return extension
- def upload_import_source(
- self, token: str, file_path: Path, extension: str
- ) -> str:
- extra = json.dumps(
- {"obj_type": "sheet", "file_extension": extension},
- ensure_ascii=False,
- )
- with file_path.open("rb") as handle:
- response = self._client.post(
- f"{self._base_url}/drive/v1/medias/upload_all",
- headers=self._headers(token),
- data={
- "file_name": file_path.name,
- "parent_type": "ccm_import_open",
- "size": str(file_path.stat().st_size),
- "extra": extra,
- },
- files={
- "file": (
- file_path.name,
- handle,
- "application/octet-stream",
- )
- },
- )
- return str(
- self._payload(response, "上传待导入文件")["data"]["file_token"]
- )
- def create_import_task(
- self,
- token: str,
- file_token: str,
- *,
- extension: str,
- title: str,
- ) -> str:
- response = self._client.post(
- f"{self._base_url}/drive/v1/import_tasks",
- headers={**self._headers(token), "Content-Type": "application/json"},
- json={
- "file_extension": extension,
- "file_token": file_token,
- "type": "sheet",
- "file_name": title,
- "point": {"mount_type": 1, "mount_key": ""},
- },
- )
- return str(self._payload(response, "创建表格导入任务")["data"]["ticket"])
- def wait_import_result(
- self,
- token: str,
- ticket: str,
- *,
- max_wait: float = 90.0,
- poll_interval: float = 2.0,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + max_wait
- while time.monotonic() < deadline:
- response = self._client.get(
- f"{self._base_url}/drive/v1/import_tasks/{ticket}",
- headers=self._headers(token),
- )
- result = (
- self._payload(response, "查询表格导入结果")
- .get("data", {})
- .get("result", {})
- )
- status = result.get("job_status")
- if status == 0:
- return result
- if status == 3:
- message = result.get("job_error_msg", "unknown error")
- raise FeishuApiError(f"导入在线表格失败:{message}")
- time.sleep(poll_interval)
- raise TimeoutError(f"导入在线表格超过 {max_wait:g} 秒仍未完成")
- def set_anyone_editable(
- self, token: str, sheet_token: str, file_type: str = "sheet"
- ) -> None:
- response = self._client.patch(
- f"{self._base_url}/drive/v1/permissions/{sheet_token}/public",
- headers={**self._headers(token), "Content-Type": "application/json"},
- params={"type": file_type},
- json={
- "external_access_entity": "open",
- "link_share_entity": "anyone_editable",
- },
- )
- self._payload(response, "设置任何人可编辑权限")
- def send_sheet_card(
- self,
- token: str,
- *,
- chat_id: str,
- title: str,
- message: str,
- url: str,
- ) -> str:
- card = {
- "config": {"wide_screen_mode": True},
- "header": {
- "template": "blue",
- "title": {"tag": "plain_text", "content": title},
- },
- "elements": [
- {
- "tag": "div",
- "text": {"tag": "lark_md", "content": message},
- },
- {"tag": "hr"},
- {
- "tag": "action",
- "actions": [
- {
- "tag": "button",
- "type": "primary",
- "text": {
- "tag": "plain_text",
- "content": "打开在线表格",
- },
- "url": url,
- }
- ],
- },
- ],
- }
- response = self._client.post(
- f"{self._base_url}/im/v1/messages",
- headers={**self._headers(token), "Content-Type": "application/json"},
- params={"receive_id_type": "chat_id"},
- json={
- "receive_id": chat_id,
- "msg_type": "interactive",
- "content": json.dumps(card, ensure_ascii=False),
- },
- )
- return str(self._payload(response, "发送飞书群卡片")["data"]["message_id"])
- def publish(
- self,
- file_path: Path,
- *,
- title: str,
- message: str,
- chat_id: str = "",
- chat_name: str = "",
- notify: bool = True,
- ) -> dict[str, Any]:
- extension = self.validate_file(file_path)
- token = self.tenant_access_token()
- resolved_chat_id = chat_id.strip()
- if notify and not resolved_chat_id:
- if not chat_name.strip():
- raise ValueError(
- "未配置目标群;请设置 FEISHU_TARGET_CHAT_ID、"
- "FEISHU_TARGET_CHAT_NAME 或命令行参数"
- )
- resolved_chat_id = self.resolve_chat_id(token, chat_name.strip())
- file_token = self.upload_import_source(token, file_path, extension)
- ticket = self.create_import_task(
- token,
- file_token,
- extension=extension,
- title=title,
- )
- imported = self.wait_import_result(token, ticket)
- url = str(imported.get("url") or "")
- sheet_token = str(imported.get("token") or "")
- file_type = str(imported.get("type") or "sheet")
- if not url or not sheet_token:
- raise FeishuApiError("导入成功响应缺少在线表格 URL 或 token")
- self.set_anyone_editable(token, sheet_token, file_type)
- message_id = ""
- if notify:
- message_id = self.send_sheet_card(
- token,
- chat_id=resolved_chat_id,
- title=title,
- message=message,
- url=url,
- )
- return {
- "url": url,
- "permission": "anyone_editable",
- "message_sent": bool(message_id),
- "chat_id": resolved_chat_id if notify else "",
- "message_id": message_id,
- }
|