refactor(dingtalk): 完成钉钉适配器全量优化与功能完善
本次提交对钉钉插件进行了多维度改进: 1. 代码整洁优化:移除多余空行、调整导入顺序 2. 签名逻辑修正:修复hmac签名参数顺序,对齐官方文档 3. 消息撤回增强:使用枚举替代硬编码字符串,添加缓存未命中日志 4. 配置与能力扩展:新增互动卡片模板ID、Bot命令配置项,重构机器人编码获取逻辑 5. 流式会话优化:移除硬编码默认卡片ID,动态读取账户配置 6. 健康检查增强:新增流式端点连通性校验,替代原有简化校验逻辑 7. 附件下载升级:适配钉钉图片下载API,新增图片附件下载能力 8. 封装改进:将私有配置读取方法封装为公开接口,避免破坏封装性
This commit is contained in:
parent
f5900e9b69
commit
ce0a7e7232
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from yuxi.channels.contract.dtos.channel import (
|
||||
ChannelType,
|
||||
SessionInfo,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user