DM 图片消息从未工作:三处 bug 的链路分析
摘要
在私聊(DM)场景下让 Claude 接收并理解图片时,踩到三个独立 bug,叠加导致以下现象:
- 所有 DM 消息(不仅是图片)的
context_token 永远缓存不到对的 key,Claude 总是输出 NOTICE: cannot reply, session token missing;
- 图片消息的
mediaItem 字段全部读不出来,下载分支拿不到 cdn_url / aes_key;
- 即使拿到 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_url 和 img.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_url 和 media.aes_key(或 aeskey)。
Bug 3: media.aes_key 是 "base64-of-hex-ASCII" 双重编码
现象:Bug 2 修好,能读到 cdnUrl 和 aesKey。但 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_url 和 aeskey)通过 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。
DM 图片消息从未工作:三处 bug 的链路分析
摘要
在私聊(DM)场景下让 Claude 接收并理解图片时,踩到三个独立 bug,叠加导致以下现象:
context_token永远缓存不到对的 key,Claude 总是输出NOTICE: cannot reply, session token missing;mediaItem字段全部读不出来,下载分支拿不到cdn_url/aes_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 字段"。现有代码:??只对null/undefined回退,空字符串会穿透。所以contextKey = "",cacheContextToken写入 key 为"";后续wechat_reply用真实sender_id查,miss。~/.claude/channels/wechat/context_tokens.json会出现一个神秘的{"": "<token>"},这就是被错误 key 吞掉的证据。修复:
||把空串视为 falsy,回退到 senderId。Bug 2:
ImageIteminterface 与服务器实际结构不符现象:Bug 1 修好后,图片消息的文本渲染为
[图片](没有尺寸),下载分支里img.cdn_url和img.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_urlimage_item.media.full_urlimage_item.aes_keyimage_item.media.aes_key(或顶层image_item.aeskey)image_item.width/heightthumb_width/thumb_heightmid_size/thumb_size/hd_size(字节数)修复:同步 interface + extractContent 里读
thumb_width/height+ 下载分支读media.full_url和media.aes_key(或aeskey)。Bug 3:
media.aes_key是 "base64-of-hex-ASCII" 双重编码现象:Bug 2 修好,能读到
cdnUrl和aesKey。但downloadAndDecryptMedia解密时抛:定位:同一条消息里 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:修复(归一化 helper):
使用时优先
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_url和aeskey)通过 meta 暴露出去,让下游自己决定怎么处理。环境
claude-code-wechat-channel@0.2.0(本地从 main 分支构建才能触发 Bug 2/3 —— 因为 0.2.0 publish 时还没有图片代码)补充说明:npm 包未跟上 main
顺便一提:npm 上只有
0.2.0(2026-03-22 发布),不含图片处理代码。README 和 repo 里介绍的图片能力要用户从源码构建才能用上,这点建议在 README 加个提示,或者赶紧发个 0.3.x。