From ce0a7e7232113f83712bdb77661196144459caca Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 9 Jul 2026 04:18:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(dingtalk):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E9=92=89=E9=92=89=E9=80=82=E9=85=8D=E5=99=A8=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=8E=E5=8A=9F=E8=83=BD=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交对钉钉插件进行了多维度改进: 1. 代码整洁优化:移除多余空行、调整导入顺序 2. 签名逻辑修正:修复hmac签名参数顺序,对齐官方文档 3. 消息撤回增强:使用枚举替代硬编码字符串,添加缓存未命中日志 4. 配置与能力扩展:新增互动卡片模板ID、Bot命令配置项,重构机器人编码获取逻辑 5. 流式会话优化:移除硬编码默认卡片ID,动态读取账户配置 6. 健康检查增强:新增流式端点连通性校验,替代原有简化校验逻辑 7. 附件下载升级:适配钉钉图片下载API,新增图片附件下载能力 8. 封装改进:将私有配置读取方法封装为公开接口,避免破坏封装性 --- .../dingtalk/adapters/directory_adapter.py | 2 +- .../dingtalk/adapters/doctor_adapter.py | 40 +++++++- .../dingtalk/adapters/inbound_adapter.py | 57 +++++++++--- .../dingtalk/adapters/mention_adapter.py | 2 +- .../dingtalk/adapters/message_ops_adapter.py | 13 ++- .../dingtalk/adapters/session_adapter.py | 1 - .../dingtalk/adapters/streaming_adapter.py | 20 ++-- .../plugins/dingtalk/dingtalk_client.py | 92 ++++++++++++++++--- .../channels/plugins/dingtalk/manifest.json | 4 +- .../channels/plugins/dingtalk/signature.py | 7 +- 10 files changed, 192 insertions(+), 46 deletions(-) diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/directory_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/directory_adapter.py index 7c3a2db4..ad5220ee 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/directory_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/directory_adapter.py @@ -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, diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/doctor_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/doctor_adapter.py index 808a934d..e33d9528 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/doctor_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/doctor_adapter.py @@ -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, ) diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/inbound_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/inbound_adapter.py index 4ab0120e..300a993d 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/inbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/inbound_adapter.py @@ -52,7 +52,7 @@ _MSG_TYPE_ACTION_CARD = "actionCard" _MSG_TYPE_FEED_CARD = "feedCard" _MSG_TYPE_INTERACTIVE = "interactive" -# 附件下载 URL scheme:dingtalk://resource?account_id=xxx&download_url=xxx&type=image +# 附件下载 URL scheme:dingtalk://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, diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/mention_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/mention_adapter.py index 33549bf7..8cc0793d 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/mention_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/mention_adapter.py @@ -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 diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/message_ops_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/message_ops_adapter.py index bc3daf87..036185b1 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/message_ops_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/message_ops_adapter.py @@ -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: diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/session_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/session_adapter.py index 09a7ebae..b83248ce 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/session_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/session_adapter.py @@ -19,7 +19,6 @@ from __future__ import annotations - from yuxi.channels.contract.dtos.channel import ( ChannelType, SessionInfo, diff --git a/backend/package/yuxi/channels/plugins/dingtalk/adapters/streaming_adapter.py b/backend/package/yuxi/channels/plugins/dingtalk/adapters/streaming_adapter.py index f66fb955..65e1222b 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/adapters/streaming_adapter.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/adapters/streaming_adapter.py @@ -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]: diff --git a/backend/package/yuxi/channels/plugins/dingtalk/dingtalk_client.py b/backend/package/yuxi/channels/plugins/dingtalk/dingtalk_client.py index dc7f3e5d..31ca3e54 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/dingtalk_client.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/dingtalk_client.py @@ -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) # ------------------------------------------------------------------ diff --git a/backend/package/yuxi/channels/plugins/dingtalk/manifest.json b/backend/package/yuxi/channels/plugins/dingtalk/manifest.json index 59777ccf..885101e0 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/manifest.json +++ b/backend/package/yuxi/channels/plugins/dingtalk/manifest.json @@ -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}, diff --git a/backend/package/yuxi/channels/plugins/dingtalk/signature.py b/backend/package/yuxi/channels/plugins/dingtalk/signature.py index 90946bb3..ea815cd7 100644 --- a/backend/package/yuxi/channels/plugins/dingtalk/signature.py +++ b/backend/package/yuxi/channels/plugins/dingtalk/signature.py @@ -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")