"""企业微信 API HTTP 客户端。 通过 httpx.AsyncClient 调用企业微信开放平台 API,封装 access_token 管理、通用请求与 IM / 通讯录 / 客户联系 / 微信客服 / OAuth API。HTTP 异常与企业微信业务错误码通过 error_translator 转换为契约层异常,不向外 抛原生异常。token 通过 CachePort 缓存,客户端本身不持有 mutable 状态。 企业微信多 Secret 权限隔离: - ``corp_secret``: 自建应用 Secret(应用消息、通讯录) - ``external_contact_secret``: 客户联系 Secret(外部联系人、客户群) - ``kf_secret``: 微信客服 Secret(客服会话) 各 Secret 独立获取 token,独立缓存。 连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过 ``attach_http_client`` 注入;未注入时降级为每请求独立 client。 依赖方向:仅 import yuxi.channels.contract.* 与标准库 / httpx,不污染 框架层。 """ 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 AuthError, DependencyError, Error 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_REFRESH_LEAD_TIME_SEC, ) # Token 类型标识 TOKEN_TYPE_APP = "app" TOKEN_TYPE_EXTERNAL_CONTACT = "external_contact" TOKEN_TYPE_KF = "kf" _TOKEN_CACHE_KEY_PREFIX = "wecom:token:" class WecomClient: """企业微信 API HTTP 客户端。 通过 DI 接收 ConfigPort / CachePort / LoggerPort,不访问全局 settings 或 logger。token 通过 CachePort 缓存,客户端不持有 mutable 状态。所有 方法为 async,HTTP 异常通过 error_translator.wrap_http_exception 转换, 企业微信业务错误码非 0 通过 error_translator.translate_wecom_error 转换。 连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过 ``attach_http_client`` 注入;未注入时降级为每请求独立 client。 """ 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 self._http_client: httpx.AsyncClient | None = None # ------------------------------------------------------------------ # 连接池管理 # ------------------------------------------------------------------ def attach_http_client(self, client: httpx.AsyncClient) -> None: """注入 lifecycle 管理的共享 httpx 连接池。""" self._http_client = client def detach_http_client(self) -> None: """解除共享连接池引用(由 lifecycle 在 onUnload 关闭前调用)。""" self._http_client = None # ------------------------------------------------------------------ # 内部辅助 # ------------------------------------------------------------------ async def _get_account_config(self, account_id: str) -> dict[str, Any]: """读取账户级配置。""" cv_corp_id = await self._config.get( "corp_id", scope=ConfigScope.ACCOUNT, target=account_id, ) cv_corp_secret = await self._config.get( "corp_secret", scope=ConfigScope.ACCOUNT, target=account_id, ) cv_agent_id = await self._config.get( "agent_id", scope=ConfigScope.ACCOUNT, target=account_id, ) return { "corp_id": cv_corp_id.value, "corp_secret": cv_corp_secret.value, "agent_id": cv_agent_id.value, } async def _get_optional_account_config(self, account_id: str, key: str) -> str | None: """读取账户级可选配置(如 external_contact_secret / kf_secret)。 @return 配置值(空字符串视为未配置返回 None);key 不存在返回 None。 @failure 配置系统故障(如 ConfigPort 不可用)向上抛 DependencyError, 不静默吞错以掩盖系统故障。 """ try: cv = await self._config.get( key, scope=ConfigScope.ACCOUNT, target=account_id, ) value = cv.value return value if value else None except KeyError: # key 不存在视为未配置 return None except DependencyError: raise except Error: raise except Exception as exc: raise DependencyError( dep="wecom_api", cause=exc, ) from exc async def _get_channel_config(self, key: str) -> Any: """读取渠道级配置。""" 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, account_id: str, token_type: str) -> str: return f"{_TOKEN_CACHE_KEY_PREFIX}{account_id}:{token_type}" async def _get_token(self, account_id: str, token_type: str) -> str: """获取指定类型的 access_token。 企业微信多 Secret 权限隔离,各 Secret 独立获取 token: - ``app``: corp_secret(自建应用) - ``external_contact``: external_contact_secret(客户联系) - ``kf``: kf_secret(微信客服) """ cache_key = self._token_cache_key(account_id, token_type) cached = await self._cache.get(cache_key) if cached.is_some(): token, expires_at = cached.unwrap() if expires_at > time.time(): return token account_config = await self._get_account_config(account_id) corp_id = account_config["corp_id"] if token_type == TOKEN_TYPE_APP: secret = account_config["corp_secret"] elif token_type == TOKEN_TYPE_EXTERNAL_CONTACT: secret = await self._get_optional_account_config(account_id, "external_contact_secret") if not secret: raise DependencyError( dep="wecom_api", cause=ValueError("external_contact_secret not configured"), ) elif token_type == TOKEN_TYPE_KF: secret = await self._get_optional_account_config(account_id, "kf_secret") if not secret: raise DependencyError( dep="wecom_api", cause=ValueError("kf_secret not configured"), ) else: raise DependencyError( dep="wecom_api", cause=ValueError(f"unknown token type: {token_type}"), ) token, expires_in = await self._fetch_token(corp_id, secret) 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( "Wecom access_token refreshed", account_id=account_id, token_type=token_type, expires_in=expires_in, ) return token async def _fetch_token(self, corp_id: str, secret: str) -> tuple[str, int]: """调用企业微信 gettoken API 获取 access_token。""" api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/cgi-bin/gettoken" params = {"corpid": corp_id, "corpsecret": secret} resp = await self._execute_http("GET", url, params=params) data = self._parse_json_response(resp) token = data.get("access_token", "") expires_in = int(data.get("expires_in", 0)) if not token or expires_in <= 0: raise DependencyError( dep="wecom_api", cause=ValueError(f"invalid token response: {data}"), ) return token, expires_in async def _execute_http( self, method: str, url: str, *, headers: dict[str, str] | None = None, params: dict | None = None, json_body: dict | None = None, data: dict | None = None, files: dict | None = None, ) -> httpx.Response: """执行 HTTP 请求,HTTP 异常转换为契约异常。""" 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: 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( "Wecom 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, "errmsg": "ok", ...}``。 errcode 非 0 时通过 error_translator.translate_wecom_error 转换。 """ data = resp.json() errcode = data.get("errcode", 0) if errcode != 0: raise error_translator.translate_wecom_error( errcode, data.get("errmsg", ""), ) from None return data async def _request_json( self, method: str, path: str, account_id: str, *, token_type: str = TOKEN_TYPE_APP, json_body: dict | None = None, params: dict | None = None, data: dict | None = None, files: dict | None = None, ) -> dict: """发起带 access_token 的请求并返回校验后的 JSON。""" token = await self._get_token(account_id, token_type) api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}{path}" all_params: dict[str, Any] = {"access_token": token} if params: all_params.update(params) resp = await self._execute_http( method, url, params=all_params, json_body=json_body, data=data, files=files, ) return self._parse_json_response(resp) # ------------------------------------------------------------------ # Token 管理 # ------------------------------------------------------------------ async def get_access_token( self, account_id: str, token_type: str = TOKEN_TYPE_APP, ) -> str: """获取指定类型的 access_token(带缓存)。""" return await self._get_token(account_id, token_type) async def verify_token(self, account_id: str) -> bool: """验证 token 有效性(调用 gettoken API)。 用于主动探测场景,强制刷新 token 以反映企业微信侧真实可用性, **不** 走缓存。``AuthError`` 视为 token 无效返回 False;其他异常 (网络故障 / 限流 / 权限不足)向上抛,由调用方处理。 """ try: account_config = await self._get_account_config(account_id) corp_id = account_config["corp_id"] secret = account_config["corp_secret"] await self._fetch_token(corp_id, secret) return True except AuthError: return False async def get_token_with_credentials( self, corp_id: str, corp_secret: str, ) -> tuple[str, int]: """使用指定凭证直接获取 access_token(不走缓存)。 用于 ``applyAccountConfig`` / ``validateCredentials`` 预取 token 验证 凭证有效性(账户配置尚未落库时无法走 ``get_access_token``)。直接 调用 ``_fetch_token``,**不** 写入缓存,仅返回 token 与 expires_in。 @failure 凭证无效抛 ``AuthError``;企业可信 IP 未配置抛 ``PermissionDeniedError``;网络故障抛 ``DependencyError``。 """ return await self._fetch_token(corp_id, corp_secret) # ------------------------------------------------------------------ # 应用消息 API # ------------------------------------------------------------------ async def send_message( self, account_id: str, msg_body: dict[str, Any], ) -> dict: """发送应用消息。 企业微信 ``POST /cgi-bin/message/send``,请求体含 ``touser`` / ``msgtype`` / 消息内容 / ``agentid``。 """ account_config = await self._get_account_config(account_id) body = dict(msg_body) body["agentid"] = int(account_config["agent_id"]) return await self._request_json( "POST", "/cgi-bin/message/send", account_id, json_body=body, ) async def recall_message( self, account_id: str, msg_id: str, ) -> dict: """撤回应用消息。""" return await self._request_json( "POST", "/cgi-bin/message/recall", account_id, json_body={"msgid": msg_id}, ) async def update_template_card( self, account_id: str, response_code: str, card_data: dict[str, Any], ) -> dict: """更新模板卡片(通过 response_code)。""" body = { "response_code": response_code, "template_card": card_data, } return await self._request_json( "POST", "/cgi-bin/message/update_template_card", account_id, json_body=body, ) # ------------------------------------------------------------------ # 媒体上传/下载 API # ------------------------------------------------------------------ async def upload_media( self, account_id: str, media_type: str, filename: str, file_data: bytes, ) -> dict: """上传临时素材。""" return await self._request_json( "POST", "/cgi-bin/media/upload", account_id, params={"type": media_type}, files={"media": (filename, file_data)}, ) async def download_media( self, account_id: str, media_id: str, ) -> bytes: """下载临时素材。 企业微信媒体下载失败时返回 HTTP 200 + JSON 错误体(如 ``{"errcode": 40007, "errmsg": "invalid media_id"}``),需校验 ``Content-Type`` 头:为 ``application/json`` 时解析并抛契约异常, 避免将错误 JSON 当作媒体字节返回(INV-7)。 """ token = await self._get_token(account_id, TOKEN_TYPE_APP) api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/cgi-bin/media/get" params = {"access_token": token, "media_id": media_id} resp = await self._execute_http("GET", url, params=params) content_type = resp.headers.get("Content-Type", "") if "application/json" in content_type: # JSON 响应意味着下载失败(media_id 无效 / 过期) self._parse_json_response(resp) return resp.content # ------------------------------------------------------------------ # 通讯录 API # ------------------------------------------------------------------ async def get_user( self, account_id: str, user_id: str, ) -> dict: """读取成员信息。""" return await self._request_json( "GET", "/cgi-bin/user/get", account_id, params={"userid": user_id}, ) async def list_user_ids( self, account_id: str, *, cursor: str = "", limit: int = 10000, ) -> dict: """获取成员 ID 列表。 企业微信 ``POST /cgi-bin/user/list_id`` 不支持按部门过滤(按部门 列出成员需用 ``/cgi-bin/user/simplelist?department_id=...``)。 ``cursor`` / ``limit`` 透传至 API 用于分页。 """ return await self._request_json( "POST", "/cgi-bin/user/list_id", account_id, json_body={"cursor": cursor, "limit": limit}, ) async def convert_to_openid( self, account_id: str, user_id: str, ) -> dict: """userid 转 openid。""" return await self._request_json( "POST", "/cgi-bin/user/convert_to_openid", account_id, json_body={"userid": user_id}, ) # ------------------------------------------------------------------ # 客户联系 API(需 external_contact_secret) # ------------------------------------------------------------------ async def get_external_contact( self, account_id: str, external_userid: str, ) -> dict: """获取外部联系人详情。""" return await self._request_json( "GET", "/cgi-bin/externalcontact/get", account_id, token_type=TOKEN_TYPE_EXTERNAL_CONTACT, params={"external_userid": external_userid}, ) async def list_external_contacts( self, account_id: str, ) -> dict: """获取外部联系人列表。""" return await self._request_json( "GET", "/cgi-bin/externalcontact/list", account_id, token_type=TOKEN_TYPE_EXTERNAL_CONTACT, ) async def list_group_chats( self, account_id: str, *, limit: int = 100, cursor: str = "", ) -> dict: """获取客户群列表。""" return await self._request_json( "POST", "/cgi-bin/externalcontact/groupchat/list", account_id, token_type=TOKEN_TYPE_EXTERNAL_CONTACT, json_body={"limit": limit, "cursor": cursor}, ) async def get_group_chat( self, account_id: str, chat_id: str, ) -> dict: """获取客户群详情。""" return await self._request_json( "POST", "/cgi-bin/externalcontact/groupchat/get", account_id, token_type=TOKEN_TYPE_EXTERNAL_CONTACT, json_body={"chat_id": chat_id}, ) async def unionid_to_external_userid( self, account_id: str, unionid: str, ) -> dict: """UnionID 转外部联系人 userid。""" return await self._request_json( "POST", "/cgi-bin/externalcontact/unionid_to_external_userid", account_id, token_type=TOKEN_TYPE_EXTERNAL_CONTACT, json_body={"unionid": unionid}, ) # ------------------------------------------------------------------ # 微信客服 API(需 kf_secret) # ------------------------------------------------------------------ async def send_kf_message( self, account_id: str, msg_body: dict[str, Any], ) -> dict: """发送微信客服消息。""" return await self._request_json( "POST", "/cgi-bin/kf/send_msg", account_id, token_type=TOKEN_TYPE_KF, json_body=msg_body, ) async def list_kf_accounts( self, account_id: str, ) -> dict: """获取客服账号列表。""" return await self._request_json( "GET", "/cgi-bin/kf/account/list", account_id, token_type=TOKEN_TYPE_KF, ) # ------------------------------------------------------------------ # OAuth API # ------------------------------------------------------------------ async def get_oauth_user_info( self, account_id: str, code: str, ) -> dict: """OAuth 授权获取访问用户身份。""" token = await self._get_token(account_id, TOKEN_TYPE_APP) api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/cgi-bin/auth/getuserinfo" params = {"access_token": token, "code": code} resp = await self._execute_http("GET", url, params=params) return self._parse_json_response(resp) async def get_user_detail( self, account_id: str, user_ticket: str, ) -> dict: """获取访问用户敏感信息(snsapi_privateinfo 模式)。""" token = await self._get_token(account_id, TOKEN_TYPE_APP) api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/cgi-bin/auth/getuserdetail" resp = await self._execute_http( "POST", url, params={"access_token": token}, json_body={"user_ticket": user_ticket}, ) return self._parse_json_response(resp) async def get_oauth_user_info_with_credentials( self, corp_id: str, corp_secret: str, code: str, ) -> dict: """OAuth 授权获取访问用户身份(凭证直传,用于 wizard 流程)。 wizard 流程中账户尚未创建,无法通过 ``account_id`` 从 ConfigPort 读取 凭证,故直接接收 ``corp_id`` / ``corp_secret`` 获取 token 后调用 ``/cgi-bin/auth/getuserinfo``。凭证来源为 ``applied_config``。 """ token, _ = await self._fetch_token(corp_id, corp_secret) api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/cgi-bin/auth/getuserinfo" params = {"access_token": token, "code": code} resp = await self._execute_http("GET", url, params=params) return self._parse_json_response(resp) async def get_user_detail_with_credentials( self, corp_id: str, corp_secret: str, user_ticket: str, ) -> dict: """获取访问用户敏感信息(凭证直传,用于 wizard snsapi_privateinfo 流程)。 与 ``get_user_detail`` 一致,但凭证直传以支持 wizard 流程(账户尚未 创建)。在 ``get_oauth_user_info_with_credentials`` 返回含 ``user_ticket`` 时调用以获取完整身份信息。 """ token, _ = await self._fetch_token(corp_id, corp_secret) api_base_url = await self._get_channel_config("api_base_url") url = f"{api_base_url}/cgi-bin/auth/getuserdetail" resp = await self._execute_http( "POST", url, params={"access_token": token}, json_body={"user_ticket": user_ticket}, ) return self._parse_json_response(resp) async def get_api_domain_ip(self, account_id: str) -> dict: """获取企业微信 API 域名 IP 段(防火墙配置辅助)。""" return await self._request_json( "GET", "/cgi-bin/get_api_domain_ip", account_id, ) __all__ = ["WecomClient"]