实现企业微信全功能渠道插件,包含适配器、客户端、签名校验、模板卡片构建等模块,完成从事件接收、会话解析到消息发送的完整流程支持,包含配置校验、错误翻译、探测能力与生命周期管理。
705 lines
24 KiB
Python
705 lines
24 KiB
Python
"""企业微信 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"]
|