"""飞书 API HTTP 客户端。 通过 httpx.AsyncClient 调用飞书开放平台 API,封装 tenant_access_token 管理、通用请求与 IM / 通讯录 / OAuth API。HTTP 异常与飞书业务错误码 通过 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 或 logger。token 通过 CachePort 缓存,客户端不持有 mutable 状态。所有 方法为 async,HTTP 异常通过 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"]