1. 移除多个文件中的空行、冗余导入
2. 修复文件末尾缺少换行符的问题
3. 新增并补全飞书多类工具API实现:
- 多维表格:更新、删除记录,列出视图
- 文档:更新、追加、删除块
- 云文档:上传、下载文件
- 群组:创建、添加成员、更新信息、创建公告
- 目录:重构用户部门缓存逻辑
4. 优化消息发送、回复、转发等API的错误处理和逻辑
5. 新增消息列表查询、已读状态查询等功能
185 lines
6.6 KiB
Python
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)
|