refactor(dingtalk): 完成钉钉适配器全量优化与功能完善

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

View File

@ -22,6 +22,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.directory import (
ChannelGroup,
ChannelUser,
@ -32,7 +33,6 @@ from yuxi.channels.contract.dtos.directory import (
GroupMember,
GroupMemberResult,
)
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.errors import (
DependencyError,
NotFoundError,

View File

@ -316,12 +316,48 @@ class DingTalkDoctorAdapter:
auto_repairable=check.auto_repairable,
repair_plan="set event_mode to stream",
)
# v1 简化:仅校验配置,连接已建立 + 心跳正常由 StreamWorker 运行时保障
# 增强:调用 get_stream_endpoint 验证能否获取 WS 接入点
client_id, client_secret = await self._read_credentials(account_id)
try:
endpoint = await self._client.get_stream_endpoint(
client_id,
client_secret,
)
except AuthError as exc:
return DiagnosticResult(
check_id=check.check_id,
passed=False,
severity=check.severity,
message=f"stream endpoint auth failed: {exc}",
auto_repairable=check.auto_repairable,
repair_plan="check client_id/client_secret",
)
except Error:
raise
except Exception as exc:
return DiagnosticResult(
check_id=check.check_id,
passed=False,
severity=check.severity,
message=f"stream endpoint fetch failed: {exc}",
auto_repairable=check.auto_repairable,
repair_plan="check network or dingtalk api availability",
)
connection_id = endpoint.get("connectionId", "")
if not connection_id:
return DiagnosticResult(
check_id=check.check_id,
passed=False,
severity=check.severity,
message="stream endpoint returned empty connectionId",
auto_repairable=check.auto_repairable,
repair_plan="check dingtalk stream permission",
)
return DiagnosticResult(
check_id=check.check_id,
passed=True,
severity=check.severity,
message="event_mode is stream; connection managed by StreamWorker",
message="stream endpoint reachable; connection managed by StreamWorker",
auto_repairable=check.auto_repairable,
repair_plan=None,
)

View File

@ -52,7 +52,7 @@ _MSG_TYPE_ACTION_CARD = "actionCard"
_MSG_TYPE_FEED_CARD = "feedCard"
_MSG_TYPE_INTERACTIVE = "interactive"
# 附件下载 URL schemedingtalk://resource?account_id=xxx&download_url=xxx&type=image
# 附件下载 URL schemedingtalk://resource?account_id=xxx&download_code=xxx&type=image
_RESOURCE_SCHEME = "dingtalk"
_RESOURCE_HOST = "resource"
@ -184,8 +184,11 @@ class DingTalkInboundAdapter:
async def downloadAttachment(self, attachment: Attachment) -> Attachment | None:
"""下载附件,返回填充 ``content`` 的 ``Attachment``。
解析 ``attachment.url````dingtalk://resource?account_id=xxx&download_url=xxx&type=image``
调用 ``DingTalkClient.download_file`` 下载二进制内容
图片附件解析 ``attachment.url````dingtalk://resource?account_id=xxx&download_code=xxx&type=image``
读取账户 ``robot_code`` 配置调用 ``DingTalkClient.download_robot_image``
先换 downloadUrl 再下载二进制
非图片附件``picUrl`` 等直接 URL直接 ``download_file``
@failure 下载失败抛 ``DependencyError``**不返回部分内容**INV-7
"""
@ -196,11 +199,25 @@ class DingTalkInboundAdapter:
return None
qs = parse_qs(parsed.query)
account_id = qs.get("account_id", [""])[0]
download_url = qs.get("download_url", [""])[0]
if not account_id or not download_url:
resource_type = qs.get("type", [""])[0]
if not account_id:
return None
try:
content = await self._client.download_file(account_id, download_url)
if resource_type == "image":
download_code = qs.get("download_code", [""])[0]
if not download_code:
return None
robot_code = await self._read_robot_code(account_id)
content = await self._client.download_robot_image(
account_id,
robot_code,
download_code,
)
else:
download_url = qs.get("download_url", [""])[0]
if not download_url:
return None
content = await self._client.download_file(account_id, download_url)
except Error:
raise
except Exception as exc:
@ -217,6 +234,21 @@ class DingTalkInboundAdapter:
size=len(content) if content else 0,
)
async def _read_robot_code(self, account_id: str) -> str:
"""从账户配置读取 ``robot_code``(图片下载需要)。"""
cv = await self._config.get(
"robot_code",
scope=ConfigScope.ACCOUNT,
target=account_id,
)
robot_code = cv.value or ""
if not robot_code:
raise ValidationError(
field="robot_code",
message="robot_code not configured for account",
)
return robot_code
async def _get_sign_secret(self) -> str:
"""从渠道级配置读取 HTTP 模式签名密钥。"""
cv = await self._config.get(
@ -259,7 +291,6 @@ def _parse_message_content(
attachment = _build_image_attachment(account_id, download_code)
# text 必须非空MessageContent 校验),图片消息用空格占位
return " ", MessageFormat.TEXT, [attachment] if attachment else []
if msg_type == _MSG_TYPE_LINK:
link_body = data.get("link", {})
if not isinstance(link_body, dict):
@ -330,15 +361,17 @@ def _parse_message_content(
def _build_image_attachment(account_id: str, download_code: str) -> Attachment | None:
"""构造图片附件URL 编码下载信息
"""构造图片附件URL 编码下载
``download_url`` 字段在运行时由事件 payload 提供钉钉图片消息需先调用
API 获取下载地址此处 ``download_url`` 暂填 ``imageCode``
``downloadAttachment`` 调用 ``download_file`` 时使用
钉钉机器人接收的图片消息含 ``imageCode``下载码非直接 URL
下载时需先调用 ``/v1.0/robot/messageFiles/download`` 换取真实
``downloadUrl`` GET 二进制内容此处将 ``download_code`` 编码到
自定义 scheme URL ``downloadAttachment`` 解析并调用
``client.download_robot_image`` 完成两步下载
"""
if not download_code:
return None
url = f"{_RESOURCE_SCHEME}://{_RESOURCE_HOST}?account_id={account_id}&download_url={download_code}&type=image"
url = f"{_RESOURCE_SCHEME}://{_RESOURCE_HOST}?account_id={account_id}&download_code={download_code}&type=image"
return Attachment(
type="image",
url=url,

View File

@ -19,11 +19,11 @@ from __future__ import annotations
from typing import Any
from yuxi.channels.contract.dtos.common import RawEvent
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.mention import (
MentionContext,
MentionType,
)
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.ports.driven.config_port import ConfigPort

View File

@ -203,7 +203,7 @@ class DingTalkMessageOpsAdapter:
has_side_effects=True,
),
MessageOperationDefinition(
operation="recall",
operation=MessageOperation.RECALL,
tool_name="撤回消息",
description="撤回指定消息24小时内",
parameters=(_PARAM_CHANNEL_MSG_ID,),
@ -226,9 +226,9 @@ class DingTalkMessageOpsAdapter:
"""
op = str(operation)
try:
if op == "recall":
if op == MessageOperation.RECALL:
ok = await self.recallMessage(parameters["channel_msg_id"])
elif op == "update_card":
elif op == MessageOperation.UPDATE_CARD:
payload = parameters["payload"]
if not isinstance(payload, FormattedMessage):
payload = FormattedMessage(content=str(payload))
@ -279,10 +279,17 @@ class DingTalkMessageOpsAdapter:
async def _check_recall_time_limit(self, channel_msg_id: str) -> None:
"""校验消息是否在撤回时限内。
缓存未命中时记录 warning 日志并放行由钉钉 API 做最终超时校验
钉钉会拒绝超过 24 小时的撤回请求errcode error_translator 翻译
@failure 超过撤回时限24 小时 ``ValidationError``
"""
opt = await self._cache.get(_msg_ts_key(channel_msg_id))
if opt.is_nothing():
await self._logger.warn(
"DingTalk recall time limit check skipped: no timestamp cached",
channel_msg_id=channel_msg_id,
)
return
sent_ts = opt.unwrap()
if time.time() - float(sent_ts) > _RECALL_TIME_LIMIT_SECONDS:

View File

@ -19,7 +19,6 @@
from __future__ import annotations
from yuxi.channels.contract.dtos.channel import (
ChannelType,
SessionInfo,

View File

@ -44,7 +44,7 @@ from yuxi.channels.contract.errors import DependencyError, Error, NotFoundError
from yuxi.channels.contract.ports.driven.cache_port import CachePort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from .._constants import DEFAULT_CARD_TEMPLATE_ID, DINGTALK_API_DEP
from .._constants import DINGTALK_API_DEP
from ..dingtalk_client import DingTalkClient
# 流式会话缓存 TTL对齐飞书实现600s 安全阀)
@ -276,6 +276,7 @@ class DingTalkStreamingAdapter:
) -> ChunkResult:
"""首块创建卡片实体,存储 outTrackId 映射。"""
robot_code = await self._get_robot_code(account_id)
card_template_id = await self._client.get_card_template_id(account_id)
out_track_id = _generate_out_track_id()
is_group = _is_group_conversation(peer_id)
conversation_id = peer_id if is_group else ""
@ -287,7 +288,7 @@ class DingTalkStreamingAdapter:
robot_code,
conversation_id,
user_id_list,
DEFAULT_CARD_TEMPLATE_ID,
card_template_id,
card_data,
out_track_id,
)
@ -349,20 +350,13 @@ class DingTalkStreamingAdapter:
return opt.unwrap()
async def _get_robot_code(self, account_id: str) -> str:
"""通过客户端读取账户级 ``robot_code``。
"""通过客户端公开方法读取账户级 ``robot_code``。
流式适配器构造器未注入 ``ConfigPort``对齐 ``entry.py`` 签名
通过 ``client._get_account_config`` 复用客户端已实现的配置读取
逻辑避免重复实现
通过 ``client.get_robot_code`` 公开方法复用客户端已实现的配置读取
逻辑避免访问私有方法破坏封装
"""
account_config = await self._client._get_account_config(account_id)
robot_code = account_config.get("robot_code", "")
if not robot_code:
raise DependencyError(
dep=DINGTALK_API_DEP,
cause=ValueError("robot_code not configured for account"),
)
return robot_code
return await self._client.get_robot_code(account_id)
def _build_chunk_card_data(chunk: StreamChunk) -> dict[str, Any]:

View File

@ -23,7 +23,7 @@ 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.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
@ -85,6 +85,48 @@ class DingTalkClient:
"""
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
# ------------------------------------------------------------------
# 内部辅助
# ------------------------------------------------------------------
@ -448,16 +490,12 @@ class DingTalkClient:
@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
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:
@ -534,6 +572,38 @@ class DingTalkClient:
)
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
# ------------------------------------------------------------------

View File

@ -102,7 +102,9 @@
{"key": "stream_endpoint_path", "type": "string", "required": false, "default": "/v1.0/gateway/connections/open", "hot_reloadable": true, "scope": "channel", "constraints": {}, "description": "钉钉 Stream 端点 API 路径,与 api_base_url 组合获取长连接接入点"},
{"key": "request_timeout_ms", "type": "integer", "required": false, "default": 30000, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 5000, "max": 60000}},
{"key": "max_retries", "type": "integer", "required": false, "default": 3, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 0, "max": 5}},
{"key": "identity_cache_ttl_sec", "type": "integer", "required": false, "default": 60, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 30, "max": 600}}
{"key": "identity_cache_ttl_sec", "type": "integer", "required": false, "default": 60, "hot_reloadable": true, "scope": "channel", "constraints": {"min": 30, "max": 600}},
{"key": "commands", "type": "json", "required": false, "default": null, "hot_reloadable": true, "scope": "channel", "constraints": {}, "description": "钉钉 Bot 菜单命令列表,每项为 {name, description, permission, is_silent}"},
{"key": "card_template_id", "type": "string", "required": false, "default": null, "hot_reloadable": true, "scope": "account", "constraints": {}, "description": "钉钉互动卡片模板 ID用于流式输出与富消息卡片渲染未配置时卡片类能力不可用"}
],
"env_vars": [
{"name": "DINGTALK_CLIENT_ID", "description": "钉钉应用 Client ID原 AppKey", "required": true, "default": null, "sensitive": false},

View File

@ -30,6 +30,10 @@ from ._constants import HTTP_SIGN_TIMESTAMP_TOLERANCE_SEC
def compute_signature(timestamp: int, sign_secret: str) -> str:
"""计算钉钉 HTTP 模式签名。
钉钉官方签名规则对齐开放平台文档
- ``string_to_sign = f"{timestamp}\\n{sign_secret}"``
- ``hmac.new(key=sign_secret, msg=string_to_sign, digestmod=sha256)``
Args:
timestamp: 回调中的 ``timestamp`` 字段毫秒级时间戳
sign_secret: HTTP 模式签名密钥``sign_secret`` 配置项
@ -39,7 +43,8 @@ def compute_signature(timestamp: int, sign_secret: str) -> str:
"""
string_to_sign = f"{timestamp}\n{sign_secret}"
hmac_code = hmac.new(
string_to_sign.encode("utf-8"),
key=sign_secret.encode("utf-8"),
msg=string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")