Hermes_agent 0.13.0钉钉文件上传没反应修复

3 阅读12分钟

钉钉文件上传修复 — 完整文档

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 下载 URLif 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.pyHermes 内置钉钉适配器
修改后/dingtalk.py同上(替换原文件)修改后替换上去
修改后/dingtalk.diff无需部署(仅参考)差异对比
修改后/SKILL_hr.md/root/.hermes/skills/hr/hr-agent/SKILL.mdHR 智能体行为指南

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/