"""钉钉 API HTTP 客户端。 通过 httpx.AsyncClient 调用钉钉开放平台 API,封装 access_token 管理、 通用请求与 IM / 通讯录 / 互动卡片 / Stream 接入 API。HTTP 异常与钉钉 业务错误码通过 error_translator 转换为契约层异常,不向外抛原生异常 (INV-7)。token 通过 CachePort 缓存(key 为 ``dingtalk:token:{client_id}``,TTL=expires_in-300s,对齐设计方案 §5.4), 客户端本身不持有 mutable 状态。 连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过 ``attach_http_client`` 注入;未注入时降级为每请求独立 client(兼容 测试与未启用 lifecycle 的场景)。 依赖方向:仅 import yuxi.channels.contract.* 与标准库 / httpx,不污染 框架层。error_translator 为本插件内部模块。 """ from __future__ import annotations 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, DINGTALK_API_DEP, TOKEN_CACHE_KEY_PREFIX, TOKEN_REFRESH_LEAD_TIME_SEC, ) class DingTalkClient: """钉钉 API HTTP 客户端。 通过 DI 接收 ConfigPort / CachePort / LoggerPort,不访问全局 settings 或 logger。token 通过 CachePort 缓存,客户端不持有 mutable 状态。所有 方法为 async,HTTP 异常通过 error_translator.wrap_http_exception 转换, 钉钉业务错误码非 0 通过 error_translator.translate_dingtalk_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 连接池。 由 ``DingTalkLifecycleHandler.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]: """读取账户级配置(client_id / client_secret / robot_code)。""" cv_client_id = await self._config.get( "client_id", scope=ConfigScope.ACCOUNT, target=account_id, ) cv_client_secret = await self._config.get( "client_secret", scope=ConfigScope.ACCOUNT, target=account_id, ) cv_robot_code = await self._config.get( "robot_code", scope=ConfigScope.ACCOUNT, target=account_id, ) return { "client_id": cv_client_id.value, "client_secret": cv_client_secret.value, "robot_code": cv_robot_code.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, client_id: str) -> str: return f"{TOKEN_CACHE_KEY_PREFIX}{client_id}" async def _get_token_for_account(self, account_id: str) -> str: """获取账户的 access_token。""" account_config = await self._get_account_config(account_id) token, _ = await self.get_access_token( account_config["client_id"], account_config["client_secret"], ) return token async def _build_auth_header(self, account_id: str) -> dict[str, str]: """获取账户 token 并构造 x-acs-dingtalk-access-token 头。 钉钉 API 通过 ``x-acs-dingtalk-access-token`` 请求头携带 ``access_token``,与飞书的 ``Authorization: Bearer xxx`` 不同。 """ token = await self._get_token_for_account(account_id) return {"x-acs-dingtalk-access-token": 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`` 保证 ``OperationTimeoutError`` 携带准确超时值。 """ 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( "DingTalk 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 响应,校验钉钉业务错误码。 ``errcode`` 非 0 时通过 error_translator.translate_dingtalk_error 转换为契约异常。``errcode`` 为 0 或缺失时返回完整响应字典。 部分钉钉端点(如 ``/v1.0/oauth2/accessToken``)将错误码放在 顶层而非 ``errcode`` 字段,由调用方在专用方法中处理。 """ data = resp.json() code = data.get("errcode", 0) if code != 0: raise error_translator.translate_dingtalk_error( code, data.get("errmsg", ""), ) 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: """发起带 access_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_access_token( self, client_id: str, client_secret: str, ) -> tuple[str, int]: """获取 access_token,缓存命中返回剩余 TTL。 钉钉 access_token 有效期 7200 秒(2 小时),缓存存储绝对过期时间戳 ``expires_at``,缓存命中时根据 ``expires_at - now`` 计算剩余 TTL, 避免长期持有 token 的调用方误判有效期。``expires_in <= 0`` 表示已 过期,调用方应触发重新获取。 钉钉 token 端点 ``/v1.0/oauth2/accessToken`` 响应不含 ``errcode`` 字段(成功响应直接含 ``accessToken`` / ``expireIn``),失败时返回 HTTP 4xx 与 ``code`` / ``message`` 字段,由 ``raise_for_status`` 触发的 ``HTTPStatusError`` 经 ``wrap_http_exception`` 转换。 @return (token, remaining_expires_in) """ cache_key = self._token_cache_key(client_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}/v1.0/oauth2/accessToken" body = {"appKey": client_id, "appSecret": client_secret} resp = await self._execute_http( "POST", url, headers={}, json_body=body, ) data = resp.json() # 钉钉 token 端点失败响应含 code/message,需显式校验 if "code" in data and data.get("code") != 0: raise error_translator.translate_dingtalk_error( int(data.get("code", 0)), data.get("message", ""), ) from None token = data["accessToken"] expires_in = int(data.get("expireIn", 0)) if expires_in <= 0: raise DependencyError( dep=DINGTALK_API_DEP, cause=ValueError(f"invalid expireIn 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( "DingTalk access_token refreshed", client_id=client_id, expires_in=expires_in, ) return token, expires_in # ------------------------------------------------------------------ # 通用请求 # ------------------------------------------------------------------ async def request( self, method: str, path: str, account_id: str, *, json: dict | None = None, params: dict | None = None, ) -> dict: """通用请求方法。 自动获取 access_token,注入 ``x-acs-dingtalk-access-token`` 头, 发起 HTTP 请求并校验业务错误码。 """ return await self._request_json( method, path, account_id, json_body=json, params=params, ) # ------------------------------------------------------------------ # 机器人消息 API(设计方案 §4.1) # ------------------------------------------------------------------ async def send_oto_message( self, account_id: str, robot_code: str, user_ids: list[str], msg_key: str, msg_param: dict, ) -> dict: """单聊消息发送(``batchSendUserMessageByRobot``)。 @return 钉钉响应字典,含 ``processQueryKey`` 用于后续撤回。 """ return await self.request( "POST", "/v1.0/robot/oToMessages/batchSend", account_id, json={ "robotCode": robot_code, "userIds": user_ids, "msgKey": msg_key, "msgParam": _to_json_str(msg_param), }, ) async def send_group_message( self, account_id: str, robot_code: str, conversation_id: str, msg_key: str, msg_param: dict, ) -> dict: """群聊消息发送(``sendGroupMessageByRobot``)。 @return 钉钉响应字典,含 ``messageId`` / ``processQueryKey``。 """ return await self.request( "POST", "/v1.0/robot/groupMessages/send", account_id, json={ "robotCode": robot_code, "openConversationId": conversation_id, "msgKey": msg_key, "msgParam": _to_json_str(msg_param), }, ) async def recall_oto_message( self, account_id: str, robot_code: str, process_query_key: str, ) -> dict: """单聊消息撤回(``batchRecallUserMessageByRobot``)。""" return await self.request( "POST", "/v1.0/robot/oToMessages/batchRecall", account_id, json={ "robotCode": robot_code, "processQueryKeys": [process_query_key], }, ) async def recall_group_message( self, account_id: str, robot_code: str, message_id: str, ) -> dict: """群聊消息撤回(``recallGroupMessageByRobot``)。""" return await self.request( "POST", "/v1.0/robot/groupMessages/recall", account_id, json={ "robotCode": robot_code, "openConversationId": "", "msgId": message_id, }, ) # ------------------------------------------------------------------ # HTTP 模式 sessionWebhook 回复(设计方案 §4.1) # ------------------------------------------------------------------ async def post_session_webhook( self, account_id: str, session_webhook: str, msg: dict, ) -> dict: """通过 HTTP 模式回调中的 sessionWebhook 临时地址直接 POST 回复。 sessionWebhook 由钉钉在 HTTP 模式回调事件中提供,含过期时间 (``sessionWebhookExpiredTime``)。本方法不携带 access_token, 仅通过 sessionWebhook 自带的临时鉴权信息投递回复。 @return 钉钉响应字典(含 ``errcode`` / ``errmsg``)。 """ try: resp = await self._execute_http( "POST", session_webhook, headers={"Content-Type": "application/json"}, json_body=msg, ) except Exception: # sessionWebhook 失败由调用方回退到机器人消息 API raise data = resp.json() code = data.get("errcode", 0) if code != 0: raise error_translator.translate_dingtalk_error( code, data.get("errmsg", ""), ) from None return data # ------------------------------------------------------------------ # 互动卡片 API(设计方案 §4.2) # ------------------------------------------------------------------ async def send_interactive_card( self, account_id: str, robot_code: str, conversation_id: str, user_id_list: list[str], card_template_id: str, card_data: dict, out_track_id: str, ) -> dict: """发送互动卡片(``/v1.0/im/interactiveCards/send``)。""" return await self.request( "POST", "/v1.0/im/interactiveCards/send", account_id, json={ "cardTemplateId": card_template_id, "openConversationId": conversation_id, "receiverUserIdList": user_id_list, "outTrackId": out_track_id, "robotCode": robot_code, "cardData": {"cardParamMap": card_data}, }, ) async def update_interactive_card( self, account_id: str, out_track_id: str, card_data: dict, ) -> dict: """更新互动卡片(``/v1.0/im/interactiveCards``)。""" return await self.request( "PUT", "/v1.0/im/interactiveCards", account_id, json={ "outTrackId": out_track_id, "cardData": {"cardParamMap": card_data}, }, ) # ------------------------------------------------------------------ # 媒体上传/下载 API(设计方案 §3.5) # ------------------------------------------------------------------ async def download_file( self, account_id: str, download_url: str, ) -> bytes: """下载文件,返回二进制内容。 钉钉文件下载使用临时 ``downloadUrl``(事件 payload 中提供),不需 额外鉴权头(URL 自带签名),但走统一连接池与超时控制。 """ resp = await self._execute_http( "GET", download_url, headers={}, ) return resp.content # ------------------------------------------------------------------ # 通讯录 API(设计方案 §3.6) # ------------------------------------------------------------------ async def get_userid_by_unionid( self, account_id: str, unionid: str, ) -> dict: """通过 unionId 查询 userId(``/topapi/user/getUseridByUnionid``)。""" return await self.request( "POST", f"/topapi/user/getUseridByUnionid?unionid={unionid}", account_id, ) async def get_user_detail_by_userid( self, account_id: str, userid: str, ) -> dict: """通过 userId 查询用户详情(``/topapi/v2/user/get``)。""" return await self.request( "POST", "/topapi/v2/user/get", account_id, json={"userid": userid}, ) async def search_users( self, account_id: str, keyword: str, ) -> dict: """搜索用户(钉钉通讯录搜索 API)。""" return await self.request( "POST", "/topapi/contact/listserver", account_id, json={"keyword": keyword}, ) async def get_group_members( self, account_id: str, conversation_id: str, cursor: int = 0, size: int = 100, ) -> dict: """查询群成员(``/topapi/im/chat/photocell/get``)。""" return await self.request( "POST", "/topapi/im/chat/photocell/get", account_id, json={ "chatId": conversation_id, "cursor": cursor, "size": size, }, ) async def get_group_detail( self, account_id: str, conversation_id: str, ) -> dict: """查询群详情(``/topapi/im/chat/get``)。""" return await self.request( "POST", "/topapi/im/chat/get", account_id, json={"chatId": conversation_id}, ) async def get_group_list( self, account_id: str, cursor: int = 0, size: int = 100, ) -> dict: """查询机器人所在群列表。""" return await self.request( "POST", "/topapi/im/chat/list", account_id, json={"cursor": cursor, "size": size}, ) # ------------------------------------------------------------------ # Stream 接入 API(设计方案 §3.1) # ------------------------------------------------------------------ async def get_stream_endpoint( self, client_id: str, client_secret: str, ) -> dict: """获取 Stream 接入端点(connectionId + endpoint)。 钉钉 Stream 模式通过 POST ``/v1.0/gateway/connections/open`` 获取 WebSocket 接入端点与 connectionId,随后由 SDK 建立 WS 长连接。 本方法不携带 access_token(端点仅校验 ClientId/ClientSecret)。 """ api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/v1.0/gateway/connections/open" body = {"clientId": client_id, "clientSecret": client_secret} resp = await self._execute_http( "POST", url, headers={}, json_body=body, ) data = resp.json() code = data.get("code", 0) if code != 0: raise error_translator.translate_dingtalk_error( int(code), data.get("message", ""), ) from None return data def _to_json_str(value: dict) -> str: """将字典序列化为 JSON 字符串。 钉钉机器人消息 API 的 ``msgParam`` 字段需为 JSON 字符串(非嵌套对象), 与飞书 ``content`` 字段约定一致。 """ import json return json.dumps(value, ensure_ascii=False) __all__ = ["DingTalkClient"]