ForcePilot/backend/package/yuxi/channels/plugins/dingtalk/dingtalk_client.py
Kris c88afb79ce feat(dingtalk): 新增钉钉渠道插件完整实现
实现钉钉企业机器人全渠道能力,包含会话解析、消息收发、富消息卡片、命令适配、身份解析、状态回传、健康探测等功能,提供完整的Stream/HTTP双模式事件接入支持,遵循单真相源设计原则,以manifest.json作为配置唯一依据。
2026-07-08 22:55:00 +08:00

671 lines
22 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 / 通讯录 / 互动卡片 / 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 状态。所有
方法为 asyncHTTP 异常通过 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"]