钉钉文件上传修复 — 完整文档
1. 这是什么
修复了 Hermes Gateway v0.13.0 在钉钉中无法接收和处理文件(PDF、DOCX、ZIP 等)的问题。
修复前:用户在钉钉里上传文件 → 智能体完全没反应(文件消息被丢弃了) 修复后:用户在钉钉里上传文件 → 智能体自动下载 → 解析 → 回复评分/内容
2. 为什么会出这个问题
2.1 钉钉推送的文件消息长什么样
当用户在钉钉里上传一个文件(比如 简历.zip),钉钉服务器会通过 Stream Mode 向智能体推送这样一条 JSON:
{
"msgtype": "file",
"content": {
"spaceId": "28800674961",
"fileName": "简历.zip",
"downloadCode": "1Pi2b9L6t4UDHZ0z2uYjub/PW34ufzcS0YvNBErm9KIoWD4...",
"fileId": "220917322708"
},
"senderNick": "帅帅",
"conversationId": "cidJJD0ZzPJ...",
"robotCode": "dingavtev91jpptj1uwz"
}
关键字段:
msgtype: "file"— 表示这是一条文件消息(不是文字,不是图片)content.downloadCode— 文件下载码,需要调用钉钉 API 才能转成真实的下载链接content.fileName— 用户上传时的原始文件名
2.2 SDK 把数据转换成了什么
钉钉的 Python SDK 在收到消息后,会调用 ChatbotMessage.from_dict() 把原始 JSON 转成一个 Python 对象。但在这个过程中,字段名被改了:
原始 JSON 字段 → 转换后的 Python 属性
─────────────────────────────────────────────────
msgtype → message.message_type (注意!不是 message.msgtype!)
content → message.extensions["content"] (注意!不是 message.content!)
而图片消息的字段被映射为:
image_content.download_code → message.image_content.download_code
2.3 原来的代码为什么漏了文件消息
Hermes 钉钉适配器处理媒体文件的方法是 _extract_media(),它只检查了:
message.image_content— 处理图片消息message.rich_text_content— 处理富文本消息(含图片/文件混排)
没有任何代码检查 message.message_type == "file" 或 message.extensions["content"] 。
结果:文件消息进来 → _extract_media() 返回"这是一条纯文本消息,没有媒体" → 文件下载码被丢弃 → 智能体看到一条空消息 → 不回复。
2.4 还有一个问题:Robot SDK 没安装
即使提取到了下载码,要把它转成真实的下载链接,需要调钉钉的 robot/messageFiles/download API。Hermes 原代码依赖 alibabacloud_dingtalk 这个 Python 包(Robot SDK)来调这个 API。
但服务器上只装了 dingtalk-stream,没装 alibabacloud_dingtalk。所以即使检测到了下载码,也会因为 Robot SDK 没初始化而无法解析。
3. 修改了什么
只修改了一个文件:
| 文件本名 | 在服务器上的完整路径 |
|---|---|
dingtalk.py | /usr/local/lib/hermes-agent/gateway/platforms/dingtalk.py |
3.1 修改点完整列表(共 7 处)
| # | 大致行号 | 方法/位置 | 做了什么 |
|---|---|---|---|
| ① | 第 97 行 | import 区域 | 新增导入 cache_document_from_bytes |
| ② | 第 ~1356 行 | _resolve_media_codes() | 新增第 3 步:检测文件消息,解析下载码 |
| ③ | 第 ~747 行 | _extract_media() | 新增两个逻辑:文件消息检测 + 文件名保存 |
| ④ | 第 ~626 行 | _on_message() 调用点 | _extract_media() 之后下载到本地缓存 |
| ⑤ | 第 ~806 行 | _download_media_to_cache() | 完全重写,支持三种输入 |
| ⑥ | 第 ~863 行 | _pick_filename() | 新增静态方法,提取文件名 |
| ⑦ | 第 ~887 行 | _resolve_download_code_http() | 新增方法,HTTP 方式解析下载码 |
3.2 修改点详细说明
修改①:新增导入
# ===== 文件位置:第 97 行 =====
# ===== 修改前 =====
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
# ===== 修改后 =====
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
cache_document_from_bytes, # ← 新增!把下载的字节数据保存到本地缓存目录
)
cache_document_from_bytes(data_bytes, filename) 是 Hermes 框架提供的工具函数,定义在 /usr/local/lib/hermes-agent/gateway/platforms/base.py 第 795 行。它接收文件内容和文件名,返回一个本地缓存路径,类似于: /root/.hermes/cache/documents/doc_abc123/简历.zip
修改②:_resolve_media_codes() 新增文件下载码解析
# ===== 文件位置:_resolve_media_codes() 方法末尾(约第 1356 行) =====
# 这个方法在 _extract_media() 之前运行,负责把下载码转成真实的下载 URL
# ===== 原来有:1. 图片下载码解析 =====
img_content = getattr(message, "image_content", None)
if img_content and getattr(img_content, "download_code", None):
codes_to_resolve.append((img_content, "download_code"))
# ===== 原来有:2. 富文本下载码解析 =====
rich_text = getattr(message, "rich_text_content", None)
if rich_text:
rich_list = getattr(rich_text, "rich_text_list", []) or []
for item in rich_list:
if isinstance(item, dict):
for key in ("downloadCode", "pictureDownloadCode", "download_code"):
if item.get(key):
codes_to_resolve.append((item, key))
# ===== 新增:3. 文件消息下载码解析 =====
# 检测 msgtype 是否为 "file"
message_type = getattr(message, "message_type", "") or ""
if message_type == "file":
# 从 extensions["content"] 中获取文件信息
extensions = getattr(message, "extensions", None) or {}
file_content = extensions.get("content", {}) if isinstance(extensions, dict) else {}
# 如果存在 downloadCode,加入解析队列
if isinstance(file_content, dict) and file_content.get("downloadCode"):
codes_to_resolve.append((file_content, "downloadCode"))
# codes_to_resolve 里的每一项都会被 _fetch_download_url() 处理
# 这个方法会调钉钉 API,把 downloadCode 替换成实际的 OSS 下载 URL
if not codes_to_resolve:
return
修改③:_extract_media() 新增文件消息检测和文件名保存
# ===== 文件位置:_extract_media() 方法(约第 747 行) =====
# 这个方法负责判断消息类型(文字/图片/文件)和提取媒体 URL
# ===== 原来代码:只检查图片 =====
image_content = getattr(message, "image_content", None)
if image_content:
download_code = getattr(image_content, "download_code", None)
if download_code:
# DingTalk sends PDF/DOCX/ZIP as "picture" type
# → 通过 URL 扩展名判断真实类型
_doc_exts = (".pdf", ".docx", ".doc", ".zip", ".7z", ".rar",
".tar", ".gz", ".tgz", ".bz2", ".xlsx", ".xls",
".pptx", ".ppt", ".txt")
_url_lower = str(download_code).lower()
if any(ext in _url_lower for ext in _doc_exts):
media_urls.append(download_code)
media_types.append("application/octet-stream")
msg_type = MessageType.DOCUMENT # 归类为文档
else:
media_urls.append(download_code)
media_types.append("image")
msg_type = MessageType.PHOTO # 归类为图片
# ===== 新增代码:检查文件消息 =====
# 获取消息的 msgtype(注意:SDK 转换后是 message_type,不是 msgtype!)
message_type = getattr(message, "message_type", "") or ""
if message_type == "file":
# 从 SDK 的 extensions dict 获取 content 信息
extensions = getattr(message, "extensions", None) or {}
file_content = extensions.get("content", {}) if isinstance(extensions, dict) else {}
if isinstance(file_content, dict) and file_content.get("downloadCode"):
dl_code = file_content.get("downloadCode")
if dl_code:
media_urls.append(dl_code) # 把下载码加入媒体列表
media_types.append("application/octet-stream") # MIME 类型
msg_type = MessageType.DOCUMENT # 消息类型标记为"文档"
# 保存原始文件名(后面缓存时要用)
original_name = file_content.get("fileName", "")
if original_name and dl_code:
# _file_names 是一个临时字典,key=下载码, value=文件名
# 因为 _download_media_to_cache 只收到下载码,不知道原始文件名
# 通过这个字典传递文件名信息
if not hasattr(self, "_file_names"):
self._file_names = {}
self._file_names[dl_code] = original_name
为什么需要 _file_names 字典?
_extract_media() 只返回三个值:(消息类型, URL列表, MIME类型列表)。但文件下载后的真实 URL 来自 OSS,URL 里不含原始文件名。如果不提前保存 fileName,缓存时文件就会变成 .bin 之类的未知扩展名,导致后续的 MCP 工具报"不支持的文件格式"。
修改④:调用点 — 在 _on_message() 中下载到本地缓存
# ===== 文件位置:_on_message() 方法(约第 626 行) =====
# ===== 修改前 =====
msg_type, media_urls, media_types = self._extract_media(message)
if not text and not media_urls:
logger.debug("[%s] Empty message, skipping", self.name)
return
# ===== 修改后 =====
msg_type, media_urls, media_types = self._extract_media(message)
# ↓↓↓ 新增这一行 ↓↓↓
media_urls, media_types = await self._download_media_to_cache(media_urls, media_types)
# 把提取到的媒体 URL/下载码 全部转成 本地缓存路径
# 这样后面的处理链路拿到的就是 /root/.hermes/cache/documents/xxx.pdf
# 而不是 https://oss.aliyuncs.com/xxxx(还要再下载一次)
if not text and not media_urls:
logger.debug("[%s] Empty message, skipping", self.name)
return
修改⑤:_download_media_to_cache() — 完全重写
这是本次修改最核心的方法。原来的方法只处理一种情况(HTTP URL → 下载 → 缓存),现在要处理三种情况。
# ===== 文件位置:新增方法(约第 806 行) =====
async def _download_media_to_cache(self, media_urls, media_types):
"""把媒体 URL 列表全部转成本地缓存路径。
这个方法的输入是 media_urls,里面可能混着三种东西:
- 真实 HTTP URL 如 https://oss.aliyuncs.com/xxx
- 下载码(base64) 如 1Pi2b9L6t4UDHZ0z2uY...
- 本地路径 如 /root/cache/xxx.pdf
输出:全部变成 cache_document_from_bytes() 产出的本地路径
"""
if not self._http_client:
return media_urls, media_types # 没有 HTTP 客户端,无法下载,原样返回
new_urls = [] # 存放处理后的路径
new_types = [] # 存放处理后的 MIME 类型
for i, url in enumerate(media_urls):
mtype = media_types[i] if i < len(media_types) else ""
# ── 情况1:HTTP/HTTPS URL ──
# 直接下载即可,不需要解析下载码
if url.startswith("http://") or url.startswith("https://"):
try:
# 用 httpx 下载文件内容
resp = await self._http_client.get(url, follow_redirects=True)
resp.raise_for_status()
data = resp.content # 文件二进制内容
# 从响应头或 URL 提取文件名
filename = DingTalkAdapter._pick_filename(resp, url)
# 保存到 Hermes 缓存目录
cached_path = cache_document_from_bytes(data, filename)
logger.info("[%s] Cached media from URL: %s -> %s (%d bytes)",
self.name, url[:80], cached_path, len(data))
new_urls.append(cached_path)
new_types.append("application/octet-stream")
except Exception as e:
logger.warning("[%s] Failed to download media %s: %s",
self.name, url[:80], e)
new_urls.append(url) # 下载失败,保留原值
new_types.append(mtype)
continue
# ── 情况2:下载码(看起来像 base64 字符串,不是 URL 也不是路径) ──
if url and not url.startswith("/") and not url.startswith("."):
try:
# 先尝试获取原始文件名(由 _extract_media 存入 _file_names)
orig_filename = getattr(self, "_file_names", {}).pop(url, None)
# 调用钉钉 HTTP API 把下载码转成真实下载 URL
resolved_url = await self._resolve_download_code_http(url)
if resolved_url:
logger.info("[%s] Resolved download code to URL: %s",
self.name, resolved_url[:80])
# 下载解析后的 URL
resp = await self._http_client.get(resolved_url, follow_redirects=True)
resp.raise_for_status()
data = resp.content
# 优先用原始文件名,否则从响应推断
filename = orig_filename or DingTalkAdapter._pick_filename(resp, resolved_url)
cached_path = cache_document_from_bytes(data, filename)
logger.info("[%s] Cached media from code: %s -> %s (%d bytes)",
self.name, url[:40], cached_path, len(data))
new_urls.append(cached_path)
new_types.append("application/octet-stream")
continue
except Exception as e:
logger.warning("[%s] Failed to resolve/download code %s: %s",
self.name, url[:40], e)
# ── 情况3:本地路径或无法识别的内容 ──
# 直接透传,不做任何处理
new_urls.append(url)
new_types.append(mtype)
return new_urls, new_types
修改⑥:_pick_filename() — 新增静态方法,提取文件名
# ===== 文件位置:新增方法(约第 863 行) =====
@staticmethod
def _pick_filename(resp, url: str) -> str:
"""从 HTTP 响应或 URL 中提取文件名。
三种提取策略,按优先级:
1. 从 Content-Disposition 响应头提取(最可靠)
2. 从 URL 路径最后一段提取
3. 从 Content-Type 响应头推断扩展名
"""
filename = None
# 策略1:解析 Content-Disposition 头
# 例如: Content-Disposition: attachment; filename="简历.pdf"
disposition = resp.headers.get("Content-Disposition", "")
if "filename=" in disposition:
match = re.search(r'filename[*]?=["']?([^"';]*)', disposition)
if match:
filename = match.group(1)
# 策略2:从 URL 路径提取
# 例如: https://oss.aliyuncs.com/yundisk0/iAEHAqRmaWxlA6h5...
# → 提取 "iAEHAqRmaWxlA6h5dW5kaXNr..."
if not filename:
from urllib.parse import urlparse, unquote
parsed = urlparse(url)
path = unquote(parsed.path)
filename = os.path.basename(path) or "document"
# 策略3:如果还是没有扩展名,根据 Content-Type 推断
# 例如: Content-Type: application/pdf → 加 .pdf
filename = os.path.basename(filename)
if not filename or "." not in filename:
content_type = resp.headers.get("Content-Type", "").lower()
if "pdf" in content_type:
filename += ".pdf"
elif "word" in content_type:
filename += ".docx"
else:
filename += ".bin"
return filename
修改⑦:_resolve_download_code_http() — 新增方法,HTTP 方式解析下载码
# ===== 文件位置:新增方法(约第 887 行) =====
async def _resolve_download_code_http(self, download_code: str) -> str:
"""用 HTTP API 把钉钉下载码转成真实的 OSS 下载 URL。
因为服务器上没有安装 alibabacloud_dingtalk 这个 Robot SDK 包,
所以直接用 httpx 调钉钉的 REST API。
API 说明:
POST https://api.dingtalk.com/v1.0/robot/messageFiles/download
Header: x-acs-dingtalk-access-token: {access_token}
Body: {"robotCode": "dingavtev91jpptj1uwz", "downloadCode": "xxx"}
返回: {"downloadUrl": "http://oss-cn-zhangjiakou.aliyuncs.com/..."}
"""
# 1. 获取钉钉 access_token
token = await self._get_access_token()
if not token:
logger.warning("[%s] No access token, cannot resolve download code", self.name)
return ""
# 2. 调钉钉 API
robot_code = self._client_id # 即 DINGTALK_CLIENT_ID: dingavtev91jpptj1uwz
try:
resp = await self._http_client.post(
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",
headers={
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
},
json={
"robotCode": robot_code,
"downloadCode": download_code,
},
)
resp.raise_for_status()
body = resp.json()
# 3. 提取下载 URL(兼容不同字段名)
download_url = body.get("downloadUrl") or body.get("download_url") or ""
return download_url
except Exception as e:
logger.warning("[%s] Failed to resolve download code via HTTP: %s", self.name, e)
return ""
4. 完整数据流(一张图看懂)
下面用文字模拟一个 PDF 简历文件的完整处理过程:
第1步:钉钉用户上传"简历.zip"
│
▼
第2步:钉钉服务器推送 Stream Mode 消息
msgtype = "file"
content.downloadCode = "1Pi2b9L6t4UDHZ0z2..."
content.fileName = "简历.zip"
│
▼
第3步:ChatbotMessage.from_dict() 转换
message.message_type = "file"
message.extensions["content"] = {downloadCode: "1Pi2b9...", fileName: "简历.zip"}
│
▼
第4步:【修改②】_resolve_media_codes()
检测 message_type == "file" ✓
从 extensions["content"] 取出 downloadCode
调用钉钉 API → downloadCode 替换为 OSS URL:
"http://oss-cn-zhangjiakou.aliyuncs.com/yundisk0/iAEHAq..."
│
▼
第5步:【修改③】_extract_media()
检测 message_type == "file" ✓
提取 downloadCode(现在已经是 URL)→ media_urls
msg_type = MessageType.DOCUMENT
保存: _file_names["http://oss..."] = "简历.zip"
│
▼
第6步:【修改④】调用 _download_media_to_cache()
│
▼
第7步:【修改⑤】_download_media_to_cache()
media_urls[0] = "http://oss..." → 是 HTTP URL → 情况1
│
httpx.get("http://oss...") → 获取 ZIP 文件二进制内容
│
【修改⑥】_pick_filename() → 如果响应有 Content-Disposition 就用它
如果没有 → 从 URL 路径提取 → 如果没有扩展名 → 根据 Content-Type 推断
│
cache_document_from_bytes(data, "简历.zip")
→ /root/.hermes/cache/documents/doc_abc123/简历.zip
│
▼
第8步:本地路径传给 AI Agent
media_urls = ["/root/.hermes/cache/documents/doc_abc123/简历.zip"]
│
▼
第9步:AI 根据 SKILL.md 的指引,调用 MCP 工具
hr_parse_file(file_path="/root/.hermes/cache/documents/doc_abc123/简历.zip")
│
▼
第10步:MCP 工具解析成功!
解压 ZIP → 提取每份简历 → 逐份调 hr_parse_resume → 返回评分
5. 文件清单
5.1 本目录文件
钉钉链接传文件/
├── README.md ← 你正在看的这个文件
├── 修改前/
│ └── dingtalk.py ← Hermes v0.13.0 原始文件(从 git 仓库导出)
└── 修改后/
├── dingtalk.py ← 修改后的完整文件(7 处修改)
├── dingtalk.diff ← dingtalk.py 的完整 git diff(203 行差异)
└── SKILL_hr.md ← HR 智能体 SKILL.md(运行时使用)
5.2 文件在服务器上的对应位置
| 本地文件 | 服务器完整路径 | 说明 |
|---|---|---|
修改前/dingtalk.py | /usr/local/lib/hermes-agent/gateway/platforms/dingtalk.py | Hermes 内置钉钉适配器 |
修改后/dingtalk.py | 同上(替换原文件) | 修改后替换上去 |
修改后/dingtalk.diff | 无需部署(仅参考) | 差异对比 |
修改后/SKILL_hr.md | /root/.hermes/skills/hr/hr-agent/SKILL.md | HR 智能体行为指南 |
5.3 dingtalk.py 的 7 处修改在文件中的位置
| # | 所在方法 | 修改类型 | 大致行号 |
|---|---|---|---|
| ① | 文件顶部 import | 新增导入 | 第 97 行 |
| ② | _resolve_media_codes() | 方法内新增代码块 | 约 1356 行 |
| ③ | _extract_media() | 方法内新增代码块(2个) | 约 747 行 |
| ④ | _on_message() | 新增 1 行调用 | 约 626 行 |
| ⑤ | _download_media_to_cache() | 整个方法重写 | 约 806-862 行 |
| ⑥ | _pick_filename() | 新增整个方法 | 约 863-886 行 |
| ⑦ | _resolve_download_code_http() | 新增整个方法 | 约 887-914 行 |
6. 如何部署
# === 假设你已经在本地把 修改后/dingtalk.py 准备好了 ===
# 1. 上传到服务器
scp -i sshdev.pem 修改后/dingtalk.py root@8.149.132.10:/usr/local/lib/hermes-agent/gateway/platforms/dingtalk.py
# 2. (可选)更新 HR 技能文档
scp -i sshdev.pem 修改后/SKILL_hr.md root@8.149.132.10:/root/.hermes/skills/hr/hr-agent/SKILL.md
# 3. 重启 Gateway 让修改生效
ssh -i sshdev.pem root@8.149.132.10 "systemctl restart hermes-gateway"
# 4. 确认启动成功
ssh -i sshdev.pem root@8.149.132.10 "systemctl status hermes-gateway | head -5"
ssh -i sshdev.pem root@8.149.132.10 "grep '✓ dingtalk connected' /root/.hermes/logs/agent.log | tail -1"
7. 如何验证修复是否生效
# 1. SSH 到服务器
ssh -i sshdev.pem root@8.149.132.10
# 2. 实时看日志
tail -f /root/.hermes/logs/agent.log
# 3. 在钉钉里上传一个文件
# 4. 观察日志输出,应该能看到类似这样的记录:
# [Dingtalk] Resolved download code to URL: http://oss-xxx...
# [Dingtalk] Cached media from code: 1Pi2b9... -> /root/.hermes/cache/documents/doc_xxx/简历.zip (254211 bytes)
# inbound message: platform=dingtalk ... msg=''
8. 常见故障
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 钉钉发文件完全没日志 | 文件消息没到达适配器 | grep "msgtype=file" /root/.hermes/logs/agent.log |
| 日志有 msgtype=file 但没解析 | _resolve_media_codes 没执行 | grep "Robot SDK not initialized" /root/.hermes/logs/agent.log |
| 下载了但提示"文件格式不支持" | 文件名丢失,扩展名错误 | grep "Cached media" /root/.hermes/logs/agent.log 看缓存文件名 |
| 缓存了但 MCP 报"文件不存在" | 路径有问题 | ls -la /root/.hermes/cache/documents/ |