ForcePilot/backend/package/yuxi/channels/plugins/wecom/wecom_client.py
Kris 28d2c19f23 feat(wecom): 新增企业微信渠道插件完整实现
实现企业微信全功能渠道插件,包含适配器、客户端、签名校验、模板卡片构建等模块,完成从事件接收、会话解析到消息发送的完整流程支持,包含配置校验、错误翻译、探测能力与生命周期管理。
2026-07-08 22:58:25 +08:00

705 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""企业微信 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 状态。所有
方法为 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"]