Files

287 lines
12 KiB
Python
Raw Permalink Normal View History

2026-04-20 15:02:17 +08:00
from __future__ import annotations
import json
2026-09-28 12:31:42 +08:00
import ssl
2026-04-20 15:02:17 +08:00
import time
import uuid
2026-05-09 14:11:02 +08:00
from collections.abc import Sequence
2026-04-20 15:02:17 +08:00
from typing import Any
from urllib import error, parse, request
from .settings import AppConfig
from .specs import FIELD_SPECS, PRIMARY_FIELD_NAME
2026-04-20 15:02:17 +08:00
FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
2026-09-28 12:31:42 +08:00
FEISHU_HTTP_RETRY_COUNT = 3
FEISHU_HTTP_RETRY_DELAY = 1.0
def create_feishu_tls12_context() -> ssl.SSLContext:
"""Create a verified TLS 1.2 context for the Feishu network path."""
context = ssl.create_default_context()
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.maximum_version = ssl.TLSVersion.TLSv1_2
return context
2026-04-20 15:02:17 +08:00
class FeishuApiError(RuntimeError):
"""Raised when the Feishu API returns a non-zero code."""
class FeishuFieldValidationError(FeishuApiError):
"""Raised when a channel-inventory table does not match its required schema."""
2026-04-20 15:02:17 +08:00
class FeishuClient:
def __init__(self, config: AppConfig) -> None:
self.config = config
2026-09-28 12:31:42 +08:00
self._opener = request.build_opener(request.HTTPSHandler(context=create_feishu_tls12_context()))
2026-04-20 15:02:17 +08:00
def request_json(
self,
url: str,
*,
method: str = "GET",
headers: dict[str, str] | None = None,
payload: dict[str, Any] | None = None,
2026-09-28 12:31:42 +08:00
retry_transport: bool = False,
2026-04-20 15:02:17 +08:00
) -> dict[str, Any]:
body = None
request_headers = {"Content-Type": "application/json; charset=utf-8"}
if headers:
request_headers.update(headers)
if payload is not None:
body = json.dumps(payload).encode("utf-8")
2026-09-28 12:31:42 +08:00
# Reads and token acquisition are idempotent. Do not automatically retry
# record-writing POST requests because a connection can fail after send.
attempts = FEISHU_HTTP_RETRY_COUNT if retry_transport or method.upper() == "GET" else 1
for attempt in range(1, attempts + 1):
req = request.Request(url, data=body, headers=request_headers, method=method)
try:
with self._opener.open(req, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"请求失败: {exc.code} {detail}") from exc
except (error.URLError, ssl.SSLError) as exc:
if attempt == attempts:
raise RuntimeError(
f"无法连接飞书接口(TLS 1.2,已尝试 {attempts} 次): {exc}"
) from exc
time.sleep(FEISHU_HTTP_RETRY_DELAY * attempt)
raise AssertionError("unreachable")
2026-04-20 15:02:17 +08:00
def get_tenant_access_token(self) -> str:
data = self.request_json(
f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal",
method="POST",
payload={"app_id": self.config.feishu_app_id, "app_secret": self.config.feishu_app_secret},
2026-09-28 12:31:42 +08:00
retry_transport=True,
2026-04-20 15:02:17 +08:00
)
if data.get("code") != 0:
raise FeishuApiError(f"获取 tenant_access_token 失败: {data}")
token = data.get("tenant_access_token")
if not token:
raise FeishuApiError("接口未返回 tenant_access_token")
return token
def build_base_url(self) -> str:
base_url = self.config.feishu_base_url
app_token = self.config.feishu_app_token
if app_token and "/base/" not in base_url and "/wiki/" not in base_url:
return f"{base_url}/{app_token}"
return base_url
def get_wiki_node_info(self, access_token: str, node_token: str) -> dict[str, Any]:
data = self.request_json(
f"{FEISHU_API_BASE}/wiki/v2/spaces/get_node?token={parse.quote(node_token)}",
headers={"Authorization": f"Bearer {access_token}"},
)
if data.get("code") != 0:
raise FeishuApiError(f"获取知识空间节点信息失败: {data}")
node_info = data.get("data", {}).get("node")
if not node_info:
raise FeishuApiError("未获取到知识空间节点信息")
return node_info
def parse_base_info(self, base_url: str, access_token: str) -> dict[str, str | None]:
parsed_url = parse.urlparse(base_url)
pathname = parsed_url.path
app_token = pathname.rstrip("/").split("/")[-1]
if "/wiki/" in pathname:
node_info = self.get_wiki_node_info(access_token, app_token)
app_token = node_info.get("obj_token", app_token)
query_params = parse.parse_qs(parsed_url.query)
return {
"base_url": base_url,
"app_token": app_token,
"selected_table_id": query_params.get("table", [None])[0],
"selected_view_id": query_params.get("view", [None])[0],
}
def enable_advanced_permission(self, access_token: str, app_token: str) -> bool:
try:
data = self.request_json(
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}",
method="PUT",
headers={"Authorization": f"Bearer {access_token}"},
payload={"is_advanced": True},
)
except Exception:
return False
return data.get("code") == 0
def _paginate(self, access_token: str, url_prefix: str, page_size: int) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
page_token = ""
while True:
query = parse.urlencode({"page_size": page_size, **({"page_token": page_token} if page_token else {})})
data = self.request_json(
f"{url_prefix}?{query}",
headers={"Authorization": f"Bearer {access_token}"},
)
if data.get("code") != 0:
raise FeishuApiError(f"分页接口失败: {data}")
items.extend(data.get("data", {}).get("items") or [])
page_token = data.get("data", {}).get("page_token") or ""
if not data.get("data", {}).get("has_more", False):
break
return items
def list_tables(self, access_token: str, app_token: str) -> list[dict[str, Any]]:
return self._paginate(access_token, f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables", 100)
def list_fields(self, access_token: str, app_token: str, table_id: str) -> list[dict[str, Any]]:
return self._paginate(access_token, f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields", 100)
def list_records(self, access_token: str, app_token: str, table_id: str) -> list[dict[str, Any]]:
return self._paginate(access_token, f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records", 500)
def send_text_message(self, access_token: str, chat_id: str, text: str) -> None:
"""Send a plain-text notification to a Feishu group chat."""
data = self.request_json(
f"{FEISHU_API_BASE}/im/v1/messages?receive_id_type=chat_id",
method="POST",
headers={"Authorization": f"Bearer {access_token}"},
payload={
"receive_id": chat_id,
"msg_type": "text",
"content": json.dumps({"text": text}, ensure_ascii=False),
},
)
if data.get("code") != 0:
raise FeishuApiError(f"发送飞书群消息失败: {data}")
2026-04-20 15:02:17 +08:00
def delete_field(self, access_token: str, app_token: str, table_id: str, field_id: str) -> None:
data = self.request_json(
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}",
method="DELETE",
headers={"Authorization": f"Bearer {access_token}"},
)
if data.get("code") != 0:
raise FeishuApiError(f"删除字段失败({field_id}): {data}")
def update_field(self, access_token: str, app_token: str, table_id: str, field_id: str, payload: dict[str, Any]) -> None:
data = self.request_json(
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}",
method="PUT",
headers={"Authorization": f"Bearer {access_token}"},
payload=payload,
)
if data.get("code") != 0:
raise FeishuApiError(f"更新字段失败({payload.get('field_name')}): {data}")
def create_field(self, access_token: str, app_token: str, table_id: str, payload: dict[str, Any]) -> None:
data = self.request_json(
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields?client_token={uuid.uuid4()}",
method="POST",
headers={"Authorization": f"Bearer {access_token}"},
payload=payload,
)
if data.get("code") != 0:
raise FeishuApiError(f"新增字段失败({payload.get('field_name')}): {data}")
def batch_delete_records(self, access_token: str, app_token: str, table_id: str, record_ids: list[str]) -> int:
deleted = 0
for start in range(0, len(record_ids), 500):
chunk = record_ids[start : start + 500]
data = self.request_json(
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete",
method="POST",
headers={"Authorization": f"Bearer {access_token}"},
payload={"records": chunk},
)
if data.get("code") != 0:
raise FeishuApiError(f"删除记录失败: {data}")
deleted += len(chunk)
time.sleep(0.2)
return deleted
def batch_create_records(self, access_token: str, app_token: str, table_id: str, records: list[dict[str, Any]]) -> int:
created = 0
for start in range(0, len(records), 500):
chunk = records[start : start + 500]
data = self.request_json(
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create?client_token={uuid.uuid4()}",
method="POST",
headers={"Authorization": f"Bearer {access_token}"},
payload={"records": chunk},
)
if data.get("code") != 0:
raise FeishuApiError(f"新增记录失败: {data}")
created += len(chunk)
time.sleep(0.2)
return created
2026-05-09 14:11:02 +08:00
def ensure_target_fields(
self,
access_token: str,
app_token: str,
table_id: str,
field_specs: Sequence[Any] = FIELD_SPECS,
) -> list[str]:
2026-04-20 15:02:17 +08:00
fields = self.list_fields(access_token, app_token, table_id)
primary_field = next((field for field in fields if field.get("is_primary")), None)
if not primary_field:
raise FeishuFieldValidationError("未找到渠道库存表主字段")
2026-04-20 15:02:17 +08:00
validation_errors: list[str] = []
2026-04-20 15:02:17 +08:00
if primary_field.get("field_name") != PRIMARY_FIELD_NAME:
validation_errors.append(
f"主字段名称错误:期望“{PRIMARY_FIELD_NAME}”,实际“{primary_field.get('field_name') or '未命名'}”"
)
if primary_field.get("type") != 1:
validation_errors.append(
f"主字段类型错误:字段“{PRIMARY_FIELD_NAME}”应为文本,实际类型 {primary_field.get('type')}"
)
2026-04-20 15:02:17 +08:00
fields_by_name = {
field.get("field_name"): field
for field in fields
if not field.get("is_primary") and field.get("field_name")
}
2026-05-09 14:11:02 +08:00
for spec in field_specs:
field = fields_by_name.get(spec.feishu_name)
if not field:
validation_errors.append(f"缺少必需字段或字段已改名:“{spec.feishu_name}”")
continue
if field.get("type") != spec.field_type:
validation_errors.append(
f"字段类型错误:“{spec.feishu_name}”应为 {spec.field_type},实际为 {field.get('type')}"
)
2026-04-22 17:12:37 +08:00
if spec.formatter is not None:
actual_formatter = (field.get("property") or {}).get("formatter")
if actual_formatter != spec.formatter:
validation_errors.append(
f"字段格式错误:“{spec.feishu_name}”应为 {spec.formatter},实际为 {actual_formatter or '未设置'}"
)
2026-04-20 15:02:17 +08:00
if validation_errors:
raise FeishuFieldValidationError(";".join(validation_errors))
return [field.get("field_name") for field in fields if field.get("field_name")]