ForcePilot/backend/package/yuxi/channels/plugins/feishu/feishu_client.py

838 lines
27 KiB
Python
Raw Normal View History

"""飞书 API HTTP 客户端。
通过 httpx.AsyncClient 调用飞书开放平台 API封装 tenant_access_token
管理通用请求与 IM / 通讯录 / OAuth APIHTTP 异常与飞书业务错误码
通过 error_translator 转换为契约层异常不向外抛原生异常token 通过
CachePort 缓存key feishu:token:{app_id}TTL=expires_in-60s
客户端本身不持有 mutable 状态
连接池由 lifecycle 统一管理onInit 创建 / onUnload 关闭通过
``attach_http_client`` 注入未注入时降级为每请求独立 client兼容
测试与未启用 lifecycle 的场景
依赖方向 import yuxi.channels.contract.* 与标准库 / httpx不污染
框架层error_translator 为本插件内部模块任务 2 产出
"""
from __future__ import annotations
import json
import time
from typing import Any
import httpx
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.errors import DependencyError
from yuxi.channels.contract.ports.driven.cache_port import CachePort
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from . import error_translator
from ._constants import CHANNEL_TARGET
_TOKEN_CACHE_KEY_PREFIX = "feishu:token:"
_APP_TOKEN_CACHE_KEY_PREFIX = "feishu:app_token:"
_TOKEN_REFRESH_LEAD_TIME_SEC = 60
class FeishuClient:
"""飞书 API HTTP 客户端。
通过 DI 接收 ConfigPort / CachePort / LoggerPort不访问全局 settings
loggertoken 通过 CachePort 缓存客户端不持有 mutable 状态所有
方法为 asyncHTTP 异常通过 error_translator.wrap_http_exception 转换
飞书业务错误码非 0 通过 error_translator.translate_feishu_error 转换
连接池由 lifecycle 统一管理onInit 创建 / onUnload 关闭通过
``attach_http_client`` 注入未注入时降级为每请求独立 client兼容
测试与未启用 lifecycle 的场景
"""
def __init__(
self,
config_port: ConfigPort,
cache_port: CachePort,
logger_port: LoggerPort,
) -> None:
self._config = config_port
self._cache = cache_port
self._logger = logger_port
# 由 lifecycle 注入的共享连接池;为 None 时降级为每请求独立 client
self._http_client: httpx.AsyncClient | None = None
# ------------------------------------------------------------------
# 连接池管理
# ------------------------------------------------------------------
def attach_http_client(self, client: httpx.AsyncClient) -> None:
"""注入 lifecycle 管理的共享 httpx 连接池。
``FeishuLifecycleHandler.onInit`` 调用将创建好的连接池注入
客户端``onUnload`` 关闭前由 lifecycle 置空以避免悬挂引用
@consistency
- 幂等重复调用以最后一次注入的 client 为准
"""
self._http_client = client
def detach_http_client(self) -> None:
"""解除共享连接池引用(由 lifecycle 在 onUnload 关闭前调用)。
@consistency
- 幂等重复调用安全
"""
self._http_client = None
# ------------------------------------------------------------------
# 内部辅助
# ------------------------------------------------------------------
async def _get_account_config(self, account_id: str) -> dict[str, Any]:
"""读取账户级配置app_id / app_secret"""
cv_app_id = await self._config.get(
"app_id",
scope=ConfigScope.ACCOUNT,
target=account_id,
)
cv_app_secret = await self._config.get(
"app_secret",
scope=ConfigScope.ACCOUNT,
target=account_id,
)
return {
"app_id": cv_app_id.value,
"app_secret": cv_app_secret.value,
}
async def _get_channel_config(self, key: str) -> Any:
"""读取渠道级配置api_base_url / request_timeout_ms"""
cv = await self._config.get(
key,
scope=ConfigScope.CHANNEL,
target=CHANNEL_TARGET,
)
return cv.value
async def _get_request_timeout_seconds(self) -> float:
"""从配置读取请求超时(秒)。"""
timeout_ms = await self._get_channel_config("request_timeout_ms")
return float(timeout_ms) / 1000.0
def _token_cache_key(self, app_id: str) -> str:
return f"{_TOKEN_CACHE_KEY_PREFIX}{app_id}"
async def _get_token_for_account(self, account_id: str) -> str:
"""获取账户的 tenant_access_token。"""
account_config = await self._get_account_config(account_id)
token, _ = await self.get_tenant_access_token(
account_config["app_id"],
account_config["app_secret"],
)
return token
async def _build_auth_header(self, account_id: str) -> dict[str, str]:
"""获取账户 token 并构造 Authorization 头。"""
token = await self._get_token_for_account(account_id)
return {"Authorization": f"Bearer {token}"}
async def _execute_http(
self,
method: str,
url: str,
headers: dict[str, str],
*,
json_body: dict | None = None,
params: dict | None = None,
data: dict | None = None,
files: dict | None = None,
) -> httpx.Response:
"""执行 HTTP 请求HTTP 异常转换为契约异常。
不负责 token 注入与业务错误码校验由调用方处理优先复用 lifecycle
注入的共享连接池未注入时降级为每请求独立 ``httpx.AsyncClient``
兼容测试与未启用 lifecycle 的场景HTTP 异常经
``error_translator.wrap_http_exception`` 转换并传入实际配置
``timeout_ms`` 保证 ``TimeoutError`` 携带准确超时值
"""
timeout = await self._get_request_timeout_seconds()
try:
if self._http_client is not None:
resp = await self._http_client.request(
method,
url,
json=json_body,
params=params,
data=data,
files=files,
headers=headers,
timeout=timeout,
)
else:
# 连接池未注入时降级为每请求独立 client兼容测试场景
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.request(
method,
url,
json=json_body,
params=params,
data=data,
files=files,
headers=headers,
)
resp.raise_for_status()
except httpx.HTTPError as exc:
await self._logger.exception(
"Feishu HTTP request failed",
exc_info=exc,
method=method,
url=url,
)
raise error_translator.wrap_http_exception(
exc,
timeout_ms=int(timeout * 1000),
) from exc
return resp
@staticmethod
def _parse_json_response(resp: httpx.Response) -> dict:
"""解析 JSON 响应,校验飞书业务错误码。
code 0 时通过 error_translator.translate_feishu_error 转换为
契约异常code 0 时返回完整响应字典
"""
data = resp.json()
code = data.get("code", 0)
if code != 0:
raise error_translator.translate_feishu_error(
code,
data.get("msg", ""),
) from None
return data
async def _request_json(
self,
method: str,
path: str,
account_id: str,
*,
json_body: dict | None = None,
params: dict | None = None,
data: dict | None = None,
files: dict | None = None,
) -> dict:
"""发起带 tenant token 的请求并返回校验后的 JSON。"""
headers = await self._build_auth_header(account_id)
api_base_url = await self._get_channel_config("api_base_url")
url = f"{api_base_url}{path}"
resp = await self._execute_http(
method,
url,
headers,
json_body=json_body,
params=params,
data=data,
files=files,
)
return self._parse_json_response(resp)
# ------------------------------------------------------------------
# Token 管理
# ------------------------------------------------------------------
async def get_tenant_access_token(
self,
app_id: str,
app_secret: str,
) -> tuple[str, int]:
"""获取 tenant_access_token缓存命中返回剩余 TTL。
缓存写入时存储绝对过期时间戳 ``expires_at``缓存命中时根据
``expires_at - now`` 计算剩余 TTL避免长期持有 token 的调用方
误判有效期``expires_in <= 0`` 表示已过期调用方应触发重新获取
@return (token, remaining_expires_in)
"""
cache_key = self._token_cache_key(app_id)
cached = await self._cache.get(cache_key)
if cached.is_some():
token, expires_at = cached.unwrap()
remaining = max(int(expires_at - time.time()), 0)
return token, remaining
api_base_url = await self._get_channel_config("api_base_url")
url = f"{api_base_url}/open-apis/auth/v3/tenant_access_token/internal"
body = {"app_id": app_id, "app_secret": app_secret}
resp = await self._execute_http(
"POST",
url,
headers={},
json_body=body,
)
data = self._parse_json_response(resp)
token = data["tenant_access_token"]
expires_in = int(data.get("expire", 0))
if expires_in <= 0:
raise DependencyError(
dep="feishu_api",
cause=ValueError(f"invalid expire value: {expires_in}"),
)
expires_at = time.time() + expires_in
ttl = max(expires_in - _TOKEN_REFRESH_LEAD_TIME_SEC, 1)
await self._cache.set(
cache_key,
(token, expires_at),
ttl_seconds=ttl,
)
await self._logger.debug(
"Feishu tenant_access_token refreshed",
app_id=app_id,
expires_in=expires_in,
)
return token, expires_in
async def get_app_access_token(
self,
app_id: str,
app_secret: str,
) -> str:
"""获取 app_access_token缓存命中返回剩余有效期内的 token。
app_access_token 用于调用飞书 OIDC用户令牌交换等需要应用级
授权的端点 tenant_access_token租户级语义不同缓存策略
对齐 ``get_tenant_access_token``存储绝对过期时间戳TTL 提前
``_TOKEN_REFRESH_LEAD_TIME_SEC`` 秒刷新
@return app_access_token 字符串
"""
cache_key = f"{_APP_TOKEN_CACHE_KEY_PREFIX}{app_id}"
cached = await self._cache.get(cache_key)
if cached.is_some():
token, expires_at = cached.unwrap()
if expires_at > time.time():
return token
api_base_url = await self._get_channel_config("api_base_url")
url = f"{api_base_url}/open-apis/auth/v3/app_access_token/internal"
body = {"app_id": app_id, "app_secret": app_secret}
resp = await self._execute_http(
"POST",
url,
headers={},
json_body=body,
)
data = self._parse_json_response(resp)
token = data["app_access_token"]
expires_in = int(data.get("expire", 0))
if expires_in <= 0:
raise DependencyError(
dep="feishu_api",
cause=ValueError(f"invalid expire value: {expires_in}"),
)
expires_at = time.time() + expires_in
ttl = max(expires_in - _TOKEN_REFRESH_LEAD_TIME_SEC, 1)
await self._cache.set(
cache_key,
(token, expires_at),
ttl_seconds=ttl,
)
await self._logger.debug(
"Feishu app_access_token refreshed",
app_id=app_id,
expires_in=expires_in,
)
return token
# ------------------------------------------------------------------
# 通用请求
# ------------------------------------------------------------------
async def request(
self,
method: str,
path: str,
account_id: str,
*,
json: dict | None = None,
params: dict | None = None,
receive_id_type: str | None = None,
) -> dict:
"""通用请求方法。
自动获取 tenant_access_token注入 Authorization 发起 HTTP
请求并校验业务错误码receive_id_type 非空时作为查询参数注入
"""
all_params: dict[str, Any] = dict(params) if params else {}
if receive_id_type is not None:
all_params["receive_id_type"] = receive_id_type
return await self._request_json(
method,
path,
account_id,
json_body=json,
params=all_params or None,
)
# ------------------------------------------------------------------
# IM API
# ------------------------------------------------------------------
async def send_message(
self,
account_id: str,
receive_id: str,
msg_type: str,
content: str,
receive_id_type: str = "open_id",
) -> dict:
"""发送消息。"""
return await self.request(
"POST",
"/open-apis/im/v1/messages",
account_id,
json={
"receive_id": receive_id,
"msg_type": msg_type,
"content": content,
},
receive_id_type=receive_id_type,
)
async def update_message(
self,
account_id: str,
message_id: str,
content: str,
) -> dict:
"""更新消息。"""
return await self.request(
"PATCH",
f"/open-apis/im/v1/messages/{message_id}",
account_id,
json={"content": content},
)
async def recall_message(
self,
account_id: str,
message_id: str,
) -> dict:
"""撤回消息。"""
return await self.request(
"DELETE",
f"/open-apis/im/v1/messages/{message_id}",
account_id,
)
async def get_message(
self,
account_id: str,
message_id: str,
) -> dict:
"""查询消息。"""
return await self.request(
"GET",
f"/open-apis/im/v1/messages/{message_id}",
account_id,
)
async def reply_message(
self,
account_id: str,
message_id: str,
msg_type: str,
content: str,
) -> dict:
"""回复消息。"""
return await self.request(
"POST",
f"/open-apis/im/v1/messages/{message_id}/reply",
account_id,
json={"msg_type": msg_type, "content": content},
)
async def batch_get_messages(
self,
account_id: str,
message_ids: list[str],
) -> dict:
"""批量查询消息。"""
return await self.request(
"GET",
"/open-apis/im/v1/messages/batch_get",
account_id,
params={"message_ids": ",".join(message_ids)},
)
async def update_card(
self,
account_id: str,
message_id: str,
card_content: dict,
) -> dict:
"""更新卡片消息。
飞书 PATCH 接口期望 ``content`` JSON 字符串对齐
``send_message`` ``msg_type=interactive`` 投递约定
"""
return await self.request(
"PATCH",
f"/open-apis/im/v1/messages/{message_id}",
account_id,
json={"content": json.dumps(card_content)},
)
async def streaming_start(
self,
account_id: str,
receive_id: str,
content: dict,
receive_id_type: str = "open_id",
) -> dict:
"""开启流式消息。
飞书流式接口期望 ``content`` JSON 字符串对齐
``send_message`` ``update_card`` 的序列化约定
"""
return await self.request(
"POST",
"/open-apis/im/v1/messages/streaming",
account_id,
json={
"receive_id": receive_id,
"content": json.dumps(content),
},
receive_id_type=receive_id_type,
)
async def streaming_update(
self,
account_id: str,
message_id: str,
content: dict,
) -> dict:
"""更新流式消息。
飞书流式接口期望 ``content`` JSON 字符串对齐
``send_message`` ``update_card`` 的序列化约定
"""
return await self.request(
"PATCH",
f"/open-apis/im/v1/messages/streaming/{message_id}",
account_id,
json={"content": json.dumps(content)},
)
async def streaming_stop(
self,
account_id: str,
message_id: str,
content: dict,
) -> dict:
"""结束流式消息。
飞书流式接口期望 ``content`` JSON 字符串对齐
``send_message`` ``update_card`` 的序列化约定
"""
return await self.request(
"POST",
f"/open-apis/im/v1/messages/streaming/{message_id}/finish",
account_id,
json={"content": json.dumps(content)},
)
async def add_reaction(
self,
account_id: str,
message_id: str,
emoji_type: str,
) -> dict:
"""添加表情回应。"""
return await self.request(
"POST",
f"/open-apis/im/v1/messages/{message_id}/reactions",
account_id,
json={"reaction_type": {"emoji_type": emoji_type}},
)
async def delete_reaction(
self,
account_id: str,
message_id: str,
reaction_id: str,
) -> dict:
"""删除表情回应。"""
return await self.request(
"DELETE",
f"/open-apis/im/v1/messages/{message_id}/reactions/{reaction_id}",
account_id,
)
async def pin_message(
self,
account_id: str,
message_id: str,
) -> dict:
"""Pin 消息。"""
return await self.request(
"POST",
"/open-apis/im/v1/pins",
account_id,
json={"message_id": message_id},
)
async def unpin_message(
self,
account_id: str,
message_id: str,
) -> dict:
"""取消 Pin 消息。"""
return await self.request(
"DELETE",
f"/open-apis/im/v1/pins/{message_id}",
account_id,
)
async def get_message_read_info(
self,
account_id: str,
message_id: str,
) -> dict:
"""查询消息已读信息。
飞书返回 ``data.read_users`` 列表 ``user_id`` / ``read_time``
群消息已读人数 = ``data.read_count``目标总数 = ``data.target_count``
"""
return await self.request(
"GET",
f"/open-apis/im/v1/messages/{message_id}/read_info",
account_id,
)
async def upload_image(
self,
account_id: str,
image_data: bytes,
image_type: str = "message",
) -> dict:
"""上传图片multipart/form-data"""
return await self._request_json(
"POST",
"/open-apis/im/v1/images",
account_id,
data={"image_type": image_type},
files={"image": ("image.png", image_data)},
)
async def upload_file(
self,
account_id: str,
file_data: bytes,
file_name: str,
file_type: str = "stream",
) -> dict:
"""上传文件multipart/form-data"""
return await self._request_json(
"POST",
"/open-apis/im/v1/files",
account_id,
data={"file_type": file_type, "file_name": file_name},
files={"file": (file_name, file_data)},
)
async def download_resource(
self,
account_id: str,
message_id: str,
file_key: str,
file_type: str = "file",
) -> bytes:
"""下载资源文件,返回二进制内容。"""
headers = await self._build_auth_header(account_id)
api_base_url = await self._get_channel_config("api_base_url")
url = f"{api_base_url}/open-apis/im/v1/messages/{message_id}/resources/{file_key}"
resp = await self._execute_http(
"GET",
url,
headers,
params={"type": file_type},
)
return resp.content
# ------------------------------------------------------------------
# 通讯录 API
# ------------------------------------------------------------------
async def get_user(
self,
account_id: str,
user_id: str,
user_id_type: str = "open_id",
) -> dict:
"""查询用户信息。"""
return await self.request(
"GET",
f"/open-apis/contact/v3/users/{user_id}",
account_id,
params={"user_id_type": user_id_type},
)
async def search_users(
self,
account_id: str,
query: str,
page_size: int = 50,
page_token: str | None = None,
) -> dict:
"""搜索用户。"""
params: dict[str, Any] = {"query": query, "page_size": page_size}
if page_token is not None:
params["page_token"] = page_token
return await self.request(
"GET",
"/open-apis/search/v1/user",
account_id,
params=params,
)
async def get_group_list(
self,
account_id: str,
page_size: int = 50,
page_token: str | None = None,
) -> dict:
"""查询群列表。"""
params: dict[str, Any] = {"page_size": page_size}
if page_token is not None:
params["page_token"] = page_token
return await self.request(
"GET",
"/open-apis/im/v1/chats",
account_id,
params=params,
)
async def get_group_members(
self,
account_id: str,
group_id: str,
page_size: int = 50,
page_token: str | None = None,
) -> dict:
"""查询群成员。"""
params: dict[str, Any] = {"page_size": page_size}
if page_token is not None:
params["page_token"] = page_token
return await self.request(
"GET",
f"/open-apis/im/v1/chats/{group_id}/members",
account_id,
params=params,
)
async def get_group_detail(
self,
account_id: str,
group_id: str,
) -> dict:
"""查询群详情。"""
return await self.request(
"GET",
f"/open-apis/im/v1/chats/{group_id}",
account_id,
)
# ------------------------------------------------------------------
# OAuth API
# ------------------------------------------------------------------
async def get_qr_login_url(
self,
account_id: str,
redirect_uri: str,
state: str,
) -> dict:
"""获取扫码登录 URL。"""
return await self.request(
"POST",
"/open-apis/authen/v1/index",
account_id,
json={"redirect_uri": redirect_uri, "state": state},
)
async def poll_qr_login_status(
self,
account_id: str,
login_id: str,
) -> dict:
"""轮询扫码登录状态。"""
return await self.request(
"GET",
f"/open-apis/authen/v1/login_state/{login_id}",
account_id,
)
async def revoke_token(
self,
account_id: str,
user_access_token: str,
) -> dict:
"""撤销用户 access token。
使用 user_access_token 而非 tenant_access_token 进行认证
account_id 仅用于定位渠道级 api_base_url请求体需携带被撤销的
``access_token``对齐飞书 authen/v1/access_tokens/revoke 协议
"""
api_base_url = await self._get_channel_config("api_base_url")
url = f"{api_base_url}/open-apis/authen/v1/access_tokens/revoke"
headers = {"Authorization": f"Bearer {user_access_token}"}
resp = await self._execute_http(
"POST",
url,
headers,
json_body={"access_token": user_access_token},
)
return self._parse_json_response(resp)
async def exchange_oauth_code(
self,
app_id: str,
app_secret: str,
code: str,
redirect_uri: str,
) -> dict:
"""用 OAuth 授权码交换 user_access_token。
两步流程
1. 通过 ``get_app_access_token`` 获取 app_access_token带缓存
2. app_access_token 调用 OIDC token 交换端点
``/open-apis/authen/v1/oidc/access_token``传入
``grant_type=authorization_code`` 与授权码
``request`` 方法不同OIDC 端点要求 app_access_token应用级
而非 tenant_access_token租户级因此不能复用 ``_build_auth_header``
@return 飞书响应字典``data`` 字段含 ``access_token`` /
``refresh_token`` / ``expires_in``
"""
app_access_token = await self.get_app_access_token(app_id, app_secret)
api_base_url = await self._get_channel_config("api_base_url")
url = f"{api_base_url}/open-apis/authen/v1/oidc/access_token"
headers = {"Authorization": f"Bearer {app_access_token}"}
resp = await self._execute_http(
"POST",
url,
headers,
json_body={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
)
return self._parse_json_response(resp)
__all__ = ["FeishuClient"]