Files
HSAP/platform/as_platform/integrations/feishu_bitable.py

300 lines
11 KiB
Python
Raw Normal View History

"""飞书多维表格 APItenant_access_token"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from as_platform.auth.feishu import _get_tenant_access_token
from as_platform.config import (
FEISHU_APP_ID,
FEISHU_APP_SECRET,
FEISHU_BITABLE_APP_TOKEN,
FEISHU_BITABLE_FIELDS,
FEISHU_BITABLE_TABLE_ID,
FEISHU_BITABLE_WIKI_NODE_TOKEN,
)
BITABLE_BASE = "https://open.feishu.cn/open-apis/bitable/v1"
WIKI_GET_NODE = "https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node"
_field_cache: dict[str, Any] | None = None
_field_cache_at: float = 0.0
_FIELD_CACHE_TTL = 300.0
_resolved_app_token: str | None = None
def _looks_like_bitable_app_token(token: str) -> bool:
t = token.strip()
return t.startswith(("Basc", "basc", "app"))
def _resolve_app_token_from_wiki(node_token: str) -> str:
with _client() as client:
access = _token(client)
resp = client.get(
WIKI_GET_NODE,
headers={"Authorization": f"Bearer {access}"},
params={"token": node_token},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "wiki get_node failed")
node = data.get("data", {}).get("node") or {}
obj = (node.get("obj_token") or "").strip()
if not obj:
raise RuntimeError(
"wiki 节点未返回 obj_token请在开放平台开通 wiki:node:read或改用 /base/Basc... 填 FEISHU_BITABLE_APP_TOKEN"
)
return obj
def _effective_app_token() -> str:
global _resolved_app_token
if FEISHU_BITABLE_APP_TOKEN and _looks_like_bitable_app_token(FEISHU_BITABLE_APP_TOKEN):
return FEISHU_BITABLE_APP_TOKEN
node = FEISHU_BITABLE_WIKI_NODE_TOKEN or FEISHU_BITABLE_APP_TOKEN
if not node:
return FEISHU_BITABLE_APP_TOKEN
if _resolved_app_token is None:
_resolved_app_token = _resolve_app_token_from_wiki(node)
return _resolved_app_token
def is_bitable_configured() -> bool:
has_app = bool(FEISHU_BITABLE_APP_TOKEN or FEISHU_BITABLE_WIKI_NODE_TOKEN)
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET and has_app and FEISHU_BITABLE_TABLE_ID)
def _client() -> httpx.Client:
return httpx.Client(timeout=60.0)
def _token(client: httpx.Client) -> str:
return _get_tenant_access_token(client)
def _app_table() -> tuple[str, str]:
return _effective_app_token(), FEISHU_BITABLE_TABLE_ID
def list_tables() -> list[dict[str, Any]]:
"""列出 Base 下数据表(运维查 table_id"""
if not is_bitable_configured():
return []
app_token, _ = _app_table()
with _client() as client:
token = _token(client)
resp = client.get(
f"{BITABLE_BASE}/apps/{app_token}/tables",
headers={"Authorization": f"Bearer {token}"},
params={"page_size": 100},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "list tables failed")
return data.get("data", {}).get("items") or []
def _load_field_meta(force: bool = False) -> dict[str, dict[str, Any]]:
global _field_cache, _field_cache_at
now = time.time()
if not force and _field_cache and now - _field_cache_at < _FIELD_CACHE_TTL:
return _field_cache
app_token, table_id = _app_table()
by_name: dict[str, dict[str, Any]] = {}
with _client() as client:
token = _token(client)
page_token: str | None = None
while True:
params: dict[str, Any] = {"page_size": 100}
if page_token:
params["page_token"] = page_token
resp = client.get(
f"{BITABLE_BASE}/apps/{app_token}/tables/{table_id}/fields",
headers={"Authorization": f"Bearer {token}"},
params=params,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "list fields failed")
for item in data.get("data", {}).get("items") or []:
name = item.get("field_name") or item.get("name")
if name:
by_name[str(name)] = item
page_token = data.get("data", {}).get("page_token")
if not page_token:
break
_field_cache = by_name
_field_cache_at = now
return by_name
def _option_id_for_text(field_meta: dict[str, Any], text: str) -> str | None:
ui = field_meta.get("ui_type") or field_meta.get("type")
if ui not in (3, "SingleSelect", "single_select"):
return None
prop = field_meta.get("property") or {}
for opt in prop.get("options") or []:
if opt.get("name") == text:
return opt.get("id")
return None
def _encode_value(field_meta: dict[str, Any], value: Any) -> Any:
if value is None:
return None
ui = field_meta.get("ui_type") or field_meta.get("type")
if ui in (1, "Text", "text", "Url", "url"):
if isinstance(value, dict) and "link" in value:
return value
return str(value)
if ui in (2, "Number", "number"):
try:
return float(value)
except (TypeError, ValueError):
return None
if ui in (3, "SingleSelect", "single_select"):
oid = _option_id_for_text(field_meta, str(value))
return oid if oid else str(value)
if ui in (5, "DateTime", "datetime", "CreatedTime", "ModifiedTime"):
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, datetime):
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
return int(datetime.now(timezone.utc).timestamp() * 1000)
if ui in (15, "Url", "url") and isinstance(value, dict):
return {"text": value.get("text") or value.get("link"), "link": value.get("link")}
return str(value)
def _flatten_cell(cell: Any) -> str:
if cell is None:
return ""
if isinstance(cell, str):
return cell.strip()
if isinstance(cell, (int, float)):
return str(cell)
if isinstance(cell, list):
parts = [_flatten_cell(x) for x in cell]
return ",".join(p for p in parts if p)
if isinstance(cell, dict):
if "text" in cell:
return str(cell.get("text") or "").strip()
if "name" in cell:
return str(cell.get("name") or "").strip()
if "value" in cell:
return _flatten_cell(cell.get("value"))
return str(cell).strip()
def list_all_records() -> list[dict[str, Any]]:
"""返回 {record_id, fields_raw, flat}。"""
if not is_bitable_configured():
return []
app_token, table_id = _app_table()
meta = _load_field_meta()
id_by_label = {label: meta[name]["field_id"] for name, m in meta.items() if (label := FEISHU_BITABLE_FIELDS.get(name)) and name in meta for name in FEISHU_BITABLE_FIELDS}
# rebuild: map logical key -> field_name
name_for_key = {k: v for k, v in FEISHU_BITABLE_FIELDS.items()}
rows: list[dict[str, Any]] = []
with _client() as client:
token = _token(client)
page_token: str | None = None
while True:
body: dict[str, Any] = {"page_size": 500}
if page_token:
body["page_token"] = page_token
resp = client.post(
f"{BITABLE_BASE}/apps/{app_token}/tables/{table_id}/records/search",
headers={"Authorization": f"Bearer {token}"},
json=body,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "search records failed")
for item in data.get("data", {}).get("items") or []:
record_id = item.get("record_id") or item.get("id")
fields = item.get("fields") or {}
flat: dict[str, str] = {}
for key, col_name in name_for_key.items():
raw = fields.get(col_name)
if raw is None:
fid = meta.get(col_name, {}).get("field_id")
if fid:
raw = fields.get(fid)
flat[key] = _flatten_cell(raw)
rows.append({"record_id": record_id, "fields": fields, "flat": flat})
page_token = data.get("data", {}).get("page_token")
if not page_token:
break
return rows
def update_record(record_id: str, values: dict[str, Any]) -> None:
"""values: 逻辑键 delivery_id / status / inbox_path ..."""
if not record_id or not is_bitable_configured():
return
meta = _load_field_meta()
payload_fields: dict[str, Any] = {}
for key, val in values.items():
if val is None:
continue
col = FEISHU_BITABLE_FIELDS.get(key)
if not col or col not in meta:
continue
encoded = _encode_value(meta[col], val)
if encoded is not None:
payload_fields[col] = encoded
if not payload_fields:
return
app_token, table_id = _app_table()
with _client() as client:
token = _token(client)
resp = client.put(
f"{BITABLE_BASE}/apps/{app_token}/tables/{table_id}/records/{record_id}",
headers={"Authorization": f"Bearer {token}"},
json={"fields": payload_fields},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "update record failed")
def connectivity_check() -> dict[str, Any]:
if not is_bitable_configured():
return {"configured": False, "ok": False, "message": "缺少 FEISHU_BITABLE_APP_TOKEN 或 TABLE_ID"}
try:
tables = list_tables()
meta = _load_field_meta(force=True)
missing = [v for k, v in FEISHU_BITABLE_FIELDS.items() if k not in ("record_id",) and v not in meta]
return {
"configured": True,
"ok": len(missing) == 0,
"tables": [{"table_id": t.get("table_id"), "name": t.get("name")} for t in tables],
"field_count": len(meta),
"missing_columns": missing,
}
except Exception as e:
msg = str(e)
hint = ""
if "1254302" in msg or "no permissions" in msg.lower():
hint = (
"飞书返回 1254302应用「主动安全算法平台」对该表无读写权。"
"知识库内嵌表常无法加企业应用,请复制为独立多维表格(/base/Basc...)并加应用协作者,"
"或见 docs/FEISHU_BITABLE_OPS.md §8联调可设 FEISHU_BITABLE_SYNC_ENABLED=0。"
)
return {"configured": True, "ok": False, "message": msg, "hint": hint or None}