ForcePilot/backend/package/yuxi/channels/plugins/wecom/wecom_client.py

705 lines
24 KiB
Python
Raw Normal View History

"""企业微信 API HTTP 客户端。
通过 httpx.AsyncClient 调用企业微信开放平台 API封装 access_token
管理通用请求与 IM / 通讯录 / 客户联系 / 微信客服 / OAuth APIHTTP
异常与企业微信业务错误码通过 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
loggertoken 通过 CachePort 缓存客户端不持有 mutable 状态所有
方法为 asyncHTTP 异常通过 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 配置值空字符串视为未配置返回 Nonekey 不存在返回 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"]