Skip to content

DM 图片消息从未工作:三处 bug 的链路分析 #14

@flamecy

Description

@flamecy

DM 图片消息从未工作:三处 bug 的链路分析

摘要

在私聊(DM)场景下让 Claude 接收并理解图片时,踩到三个独立 bug,叠加导致以下现象:

  1. 所有 DM 消息(不仅是图片)的 context_token 永远缓存不到对的 key,Claude 总是输出 NOTICE: cannot reply, session token missing
  2. 图片消息的 mediaItem 字段全部读不出来,下载分支拿不到 cdn_url / aes_key
  3. 即使拿到 key 尝试解密,也会以 RangeError: Invalid key length 失败。

这三个 bug 叠在一起让 main 分支的 DM 图片链路从未成功过。下面按触发顺序给出定位和修复。


Bug 1: group_id = "" 穿透 ??,DM 的 context_token 缓存 key 永远为空

现象:任何 DM 消息到达后,Claude 看到 can_reply=false,输出 NOTICE。但服务器返回里 context_token 字段是正常存在的(用文件日志 dump 过,字段确实 PRESENT)。

定位:服务器对 DM 消息返回 "group_id": ""(空字符串,不是 undefined),而不是我们想当然以为的 "DM 没有 group_id 字段"。现有代码:

const contextKey = groupId ?? senderId;

?? 只对 null/undefined 回退,空字符串会穿透。所以 contextKey = ""cacheContextToken 写入 key 为 "";后续 wechat_reply 用真实 sender_id 查,miss。~/.claude/channels/wechat/context_tokens.json 会出现一个神秘的 {"": "<token>"},这就是被错误 key 吞掉的证据。

修复

- const contextKey = groupId ?? senderId;
+ const contextKey = groupId || senderId;

|| 把空串视为 falsy,回退到 senderId。


Bug 2: ImageItem interface 与服务器实际结构不符

现象:Bug 1 修好后,图片消息的文本渲染为 [图片](没有尺寸),下载分支里 img.cdn_urlimg.aes_key 都是 undefined

实际服务器 payload(用文件日志 dump 的一条真实图片消息,敏感字段已脱敏):

{
  "item_list": [{
    "type": 2,
    "image_item": {
      "aeskey": "0123456789abcdef0123456789abcdef",
      "media": {
        "aes_key": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
        "encrypt_query_param": "<base64 blob>",
        "full_url": "https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=...&taskid=..."
      },
      "mid_size": 98721,
      "thumb_size": 8909,
      "thumb_height": 210,
      "thumb_width": 157,
      "hd_size": 0
    }
  }]
}

与代码定义的 interface 对照:

代码假设 实际字段
image_item.cdn_url image_item.media.full_url
image_item.aes_key image_item.media.aes_key(或顶层 image_item.aeskey
image_item.width / height 不存在 — 只有 thumb_width / thumb_height
mid_size / thumb_size / hd_size(字节数)

修复:同步 interface + extractContent 里读 thumb_width/height + 下载分支读 media.full_urlmedia.aes_key(或 aeskey)。


Bug 3: media.aes_key 是 "base64-of-hex-ASCII" 双重编码

现象:Bug 2 修好,能读到 cdnUrlaesKey。但 downloadAndDecryptMedia 解密时抛:

RangeError: Invalid key length
    at Decipheriv.createCipherBase (node:internal/crypto/cipher:121:19)
    at decryptAesEcb (dist/wechat-channel.js:...)

定位:同一条消息里 key 以两种格式同时出现:

  • image_item.aeskey = 0123456789abcdef0123456789abcdef(32 字符 hex)
  • image_item.media.aes_key = MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=(44 字符 base64)

关键:后者 base64 解码得到的不是原始 16 字节 key,而是前者那个 hex 字符串的 ASCII 表示(32 字节)。换句话说,media.aes_key 是 "base64(hex(raw_key))" 的双重编码。

现有 decryptAesEcb

function decryptAesEcb(data: Buffer, keyBase64: string): Buffer {
  const key = Buffer.from(keyBase64, "base64");          // 32 bytes (ASCII hex)
  const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);  // ← 期望 16 bytes, 抛错
  ...
}

修复(归一化 helper):

function normalizeClawBotAesKey(keyField: string): Buffer {
  // Case 1: 32-char hex (image_item.aeskey)
  if (/^[0-9a-fA-F]{32}$/.test(keyField)) {
    return Buffer.from(keyField, "hex");
  }
  // Case 2: base64 of 32-char hex-ASCII (image_item.media.aes_key)
  const decoded = Buffer.from(keyField, "base64").toString("utf-8");
  if (/^[0-9a-fA-F]{32}$/.test(decoded)) {
    return Buffer.from(decoded, "hex");
  }
  // Case 3: plain base64 of raw 16 bytes (legacy / outgoing path)
  const raw = Buffer.from(keyField, "base64");
  if (raw.length === 16) return raw;
  throw new Error(`Cannot normalize AES key (len=${keyField.length})`);
}

使用时优先 image_item.aeskey(Case 1,最干净)。


额外建议:把图片 body 实际传给 Claude

以上三个修完之后,现有 MSG_ITEM_IMAGE 分支只把 [图片 WxH] 作为文本传给 Claude,mediaItem 本身被丢弃在 extractContent 之外,通道 notification 也不带图。Claude 看不到像素,只能 echo [图片] 或道歉。

实测可行的做法:收到图片后立即下载 + 解密,写入 ${os.tmpdir()}/wechat-channel-media/<sha1>.<ext>(扩展名用魔数识别 jpg/png/gif/webp),然后在 channel content 里追加 image_path=<path>,并在 MCP instructions 里告诉 Claude "看到 image_path 就用 Read 工具读"。

这样 Claude 就能实际理解图片内容、按用户问题分析/描述。如果这不在项目 scope 内,至少应该把完整 image_item(含 media.full_urlaeskey)通过 meta 暴露出去,让下游自己决定怎么处理。


环境

  • 包版本:npm claude-code-wechat-channel@0.2.0(本地从 main 分支构建才能触发 Bug 2/3 —— 因为 0.2.0 publish 时还没有图片代码)
  • Node v20.20.1 / Bun 1.3.11
  • macOS 25.4.0
  • Claude Code 2.1.114
  • WeChat iOS 最新版 + ClawBot 插件

补充说明:npm 包未跟上 main

顺便一提:npm 上只有 0.2.0(2026-03-22 发布),不含图片处理代码。README 和 repo 里介绍的图片能力要用户从源码构建才能用上,这点建议在 README 加个提示,或者赶紧发个 0.3.x。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions