ForcePilot/backend/package/yuxi/channels/adapters/feishu/api.py
Kris 69b7139863 refactor(feishu): 清理冗余代码并补全飞书工具集实现
1.  移除多个文件中的空行、冗余导入
2.  修复文件末尾缺少换行符的问题
3.  新增并补全飞书多类工具API实现:
    - 多维表格:更新、删除记录,列出视图
    - 文档:更新、追加、删除块
    - 云文档:上传、下载文件
    - 群组:创建、添加成员、更新信息、创建公告
    - 目录:重构用户部门缓存逻辑
4.  优化消息发送、回复、转发等API的错误处理和逻辑
5.  新增消息列表查询、已读状态查询等功能
2026-05-13 16:07:59 +08:00

185 lines
6.6 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import Any
import httpx
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
FEISHU_BUSINESS_ERROR_CODES: dict[int, str] = {
99991663: "app_access_token 无效",
99991672: "tenant_access_token 已过期",
99991664: "请求频繁,触发限流",
99991668: "app_ticket 无效",
99991669: "app_access_token 已过期",
230001: "频率限制",
230002: "请求过于频繁",
}
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1.0
TOKEN_CACHE_TTL_S = 3600
RATE_LIMIT_PER_MINUTE = 100
RATE_LIMIT_BURST = 10
class TokenBucket:
def __init__(self, rate_per_minute: int = RATE_LIMIT_PER_MINUTE, burst: int = RATE_LIMIT_BURST):
self._rate = rate_per_minute / 60.0
self._burst = burst
self._tokens = float(burst)
self._last_refill = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> bool:
async with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return True
return False
async def wait_and_acquire(self) -> None:
while not await self.acquire():
await asyncio.sleep(0.1)
class FeishuApiClient:
DEFAULT_TIMEOUT_S = 30.0
def __init__(
self,
lark_client,
http_client: httpx.AsyncClient | None = None,
timeout: float | None = None,
max_retries: int = MAX_RETRIES,
):
self._lark = lark_client
self._timeout = timeout or self.DEFAULT_TIMEOUT_S
self._max_retries = max_retries
self._token_bucket = TokenBucket()
self._http: httpx.AsyncClient | None = http_client
self._owns_http = http_client is None
self._token_cache: tuple[str, float] | None = None
self._token_lock = asyncio.Lock()
@property
def domain(self) -> str:
return getattr(self._lark, "domain", "https://open.feishu.cn")
async def _ensure_http(self) -> httpx.AsyncClient:
if self._http is None:
self._http = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._owns_http = True
return self._http
async def close(self) -> None:
if self._owns_http and self._http is not None:
await self._http.aclose()
self._http = None
async def _get_token(self) -> str:
async with self._token_lock:
if self._token_cache is not None:
token, cached_at = self._token_cache
if time.monotonic() - cached_at < TOKEN_CACHE_TTL_S:
return token
resp = await asyncio.to_thread(self._lark.auth.tenant_access_token_internal)
if not resp.success():
raise RuntimeError(f"Token acquisition failed: {resp.msg}")
self._token_cache = (resp.token, time.monotonic())
return resp.token
def _parse_business_error(self, resp_data: dict) -> str | None:
code = resp_data.get("code", 0)
if code != 0:
base_msg = FEISHU_BUSINESS_ERROR_CODES.get(int(code), f"飞书业务错误码: {code}")
detail = resp_data.get("msg", "")
return f"{base_msg} ({detail})" if detail else base_msg
return None
async def _request_with_retry(self, method: str, path: str, body: dict | None = None, **params: Any) -> dict:
last_error: Exception | None = None
for attempt in range(self._max_retries + 1):
await self._token_bucket.wait_and_acquire()
try:
token = await self._get_token()
http = await self._ensure_http()
headers = {"Authorization": f"Bearer {token}"}
url = f"{self.domain}{path}"
request_args: dict[str, Any] = {"headers": headers}
if params:
request_args["params"] = params
if body is not None and method.lower() in ("post", "patch", "put"):
request_args["json"] = body
resp = await http.request(method, url, **request_args)
if resp.status_code == 429 and attempt < self._max_retries:
retry_after = float(resp.headers.get("Retry-After", "1"))
await asyncio.sleep(retry_after)
continue
if resp.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries:
wait = RETRY_BACKOFF_BASE * (2**attempt)
await asyncio.sleep(wait)
continue
resp_data = resp.json() if resp.text else {}
if resp.status_code == 401 and attempt < self._max_retries:
self._token_cache = None
continue
if resp.status_code != 200:
business_err = self._parse_business_error(resp_data) if isinstance(resp_data, dict) else None
err_msg = business_err or f"HTTP {resp.status_code}: {resp.text[:300]}"
raise RuntimeError(err_msg)
if isinstance(resp_data, dict):
business_err = self._parse_business_error(resp_data)
if business_err:
raise RuntimeError(business_err)
return resp_data
except (httpx.TimeoutException, httpx.NetworkError) as e:
last_error = e
if attempt < self._max_retries:
wait = RETRY_BACKOFF_BASE * (2**attempt)
await asyncio.sleep(wait)
continue
raise RuntimeError(f"Network error: {e}") from e
except RuntimeError:
raise
except Exception as e:
last_error = e
if attempt < self._max_retries:
await asyncio.sleep(RETRY_BACKOFF_BASE * (2**attempt))
continue
raise
raise last_error or RuntimeError("Request failed after retries")
async def get(self, path: str, **params: Any) -> dict:
return await self._request_with_retry("GET", path, **params)
async def post(self, path: str, body: dict | None = None) -> dict:
return await self._request_with_retry("POST", path, body=body)
async def patch(self, path: str, body: dict) -> dict:
return await self._request_with_retry("PATCH", path, body=body)
async def delete(self, path: str) -> dict:
return await self._request_with_retry("DELETE", path)