ForcePilot/backend/package/yuxi/channels/plugins/dingtalk/dingtalk_client.py
Kris ce0a7e7232 refactor(dingtalk): 完成钉钉适配器全量优化与功能完善
本次提交对钉钉插件进行了多维度改进:
1.  代码整洁优化:移除多余空行、调整导入顺序
2.  签名逻辑修正:修复hmac签名参数顺序,对齐官方文档
3.  消息撤回增强:使用枚举替代硬编码字符串,添加缓存未命中日志
4.  配置与能力扩展:新增互动卡片模板ID、Bot命令配置项,重构机器人编码获取逻辑
5.  流式会话优化:移除硬编码默认卡片ID,动态读取账户配置
6.  健康检查增强:新增流式端点连通性校验,替代原有简化校验逻辑
7.  附件下载升级:适配钉钉图片下载API,新增图片附件下载能力
8.  封装改进:将私有配置读取方法封装为公开接口,避免破坏封装性
2026-07-09 04:18:06 +08:00

741 lines
25 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, ValidationError
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_robot_code(self, account_id: str) -> str:
"""读取账户级 ``robot_code``。
供适配器streaming / message_ops 等)调用,避免直接访问
``_get_account_config`` 私有方法。
@failure ``robot_code`` 为空时抛 ``ValidationError``。
"""
account_config = await self._get_account_config(account_id)
robot_code = account_config.get("robot_code", "") or ""
if not robot_code:
raise ValidationError(
field="robot_code",
message="robot_code not configured for account",
)
return robot_code
async def get_card_template_id(self, account_id: str) -> str:
"""读取账户级 ``card_template_id``(互动卡片模板 ID
供 streaming / rich_message 适配器调用。未配置时抛
``ValidationError``,不使用占位值掩盖配置缺失问题。
@failure ``card_template_id`` 为空时抛 ``ValidationError``。
"""
cv = await self._config.get(
"card_template_id",
scope=ConfigScope.ACCOUNT,
target=account_id,
)
template_id = cv.value or ""
if not template_id:
raise ValidationError(
field="card_template_id",
message="card_template_id not configured for account",
)
return template_id
# ------------------------------------------------------------------
# 内部辅助
# ------------------------------------------------------------------
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``)。
"""
resp = await self._execute_http(
"POST",
session_webhook,
headers={"Content-Type": "application/json"},
json_body=msg,
)
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
async def download_robot_image(
self,
account_id: str,
robot_code: str,
download_code: str,
) -> bytes:
"""下载机器人接收的图片(钉钉 ``/v1.0/robot/messageFiles/download``)。
钉钉机器人接收的图片消息含 ``imageCode``(下载码),非直接 URL。
需先调用 ``/v1.0/robot/messageFiles/download`` 用 ``downloadCode`` +
``robotCode`` 换取临时 ``downloadUrl``,再 GET 下载二进制内容。
@failure 钉钉 API 返回非 0 errcode 抛契约异常HTTP 故障抛
``DependencyError``。
"""
result = await self.request(
"POST",
"/v1.0/robot/messageFiles/download",
account_id,
json={
"downloadCode": download_code,
"robotCode": robot_code,
},
)
download_url = result.get("downloadUrl", "")
if not download_url:
raise DependencyError(
dep=DINGTALK_API_DEP,
cause=ValueError("dingtalk messageFiles/download returned empty downloadUrl"),
)
return await self.download_file(account_id, download_url)
# ------------------------------------------------------------------
# 通讯录 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"]