微信机器人接 iLink 踩坑:图片/文件是加密的,AI 看不到
接入微信 iLink/ClawBot 后,我发现微信消息远不止文本。图片、PDF 等媒体文件走的是 AES-128-ECB 加密 CDN,daemon 必须先解密、落盘,再交给 AI 处理。本文完整记录 Molio 的实现链路、踩坑过程与测试策略,相关代码位于
apps/daemon/src/core/weixin/。
一、背景:为什么不是"拿到 URL 就能用"
我做了一个本地 AI 知识操作系统 Molio,其中一个核心能力是把微信消息接入 AI 工作流。接入微信 iLink/ClawBot 后,我原以为消息处理就是:
- 调
getUpdates拿消息 - 提取文本
- 把文本交给 AI
直到用户发来一张截图,AI 告诉我"我看不了这张图"——明明消息里就有 URL。
问题就出在这里:微信给的不是明文文件,而是加密后的 CDN 密文。
二、消息结构:别信 message_type,信 item_list
ClawBot 通过 POST /ilink/bot/getupdates 返回消息。一个容易踩的坑是:message_type 是顶层字段,但真实类型在 item_list 里。
{
"message_id": "747503057...",
"from_user_id": "o9cq809PMBd6...",
"message_type": 1,
"item_list": [
{
"type": 1,
"text_item": { "text": "帮我看看这份报告" }
},
{
"type": 4,
"file_item": {
"file_name": "Q4分析报告.pdf",
"len": "446606",
"md5": "10aa4843...",
"media": {
"full_url": "https://novac2c.cdn.weixin.qq.com/c2c/download?...",
"aes_key": "NDBjZmRiN2RhZDhmO..."
}
}
}
]
}
关键判断:message_type 不准,图片/文件消息也可能为 1。 正确做法是遍历 item_list,根据每个 item 的 type 处理。
| type | 类型 | 处理状态 |
|---|---|---|
| 1 | 文本 | ✅ 直接解析 |
| 2 | 图片 | ✅ 下载 + AES 解密 |
| 3 | 语音 | ⏳ 待支持 |
| 4 | 文件 | ✅ 下载 + AES 解密 |
| 5 | 视频 | ⏳ 待支持 |
所以第一步就是移除 if (raw.message_type !== 1) return; 这种硬过滤。
三、核心问题:CDN 下载下来是密文
拿到 media.full_url 后,直接 fetch:
curl "https://novac2c.cdn.weixin.qq.com/c2c/download?..." > image.jpg
# 打开 → 损坏
下载下来的是 AES-128-ECB 加密密文,不是图片本身。
而且 aes_key 有两种形式:
image_item.aeskey:32 字符 hex 字符串,如24dae86aeb24d7a2069b7b852dec5bc3media.aes_key:base64 编码的 hex 字符串
两种都必须兼容处理。
四、完整实现:从解析到落盘
4.1 提取附件
extractAttachments 把 file_item / image_item 统一抽取为结构化的 WeixinAttachment:
export function extractAttachments(items: WeixinRawItem[]): WeixinAttachment[] {
const out = [];
for (const item of items) {
if (item.file_item) {
const url = mediaUrl(item);
if (!url) continue;
out.push({
kind: 'file',
url,
fileName: item.file_item.file_name,
size: normalizeSize(item.file_item.len),
aesKey: item.file_item.media?.aes_key,
});
} else if (item.image_item) {
const url = mediaUrl(item);
if (!url) continue;
out.push({
kind: 'image',
url,
width: item.image_item.thumb_width,
height: item.image_item.thumb_height,
size: item.image_item.hd_size,
aesKey: item.image_item.aeskey ?? item.image_item.media?.aes_key,
});
}
}
return out;
}
注意图片的 aesKey 有两个来源:image_item.aeskey 和 media.aes_key,文件消息只有 media.aes_key。
4.2 下载 + 解密 + 落盘
materializeAttachments 负责"物化"流程:
export async function materializeAttachments(
message: ParsedWeixinMessage,
cwd: string | undefined,
downloadFn?: DownloadMediaFn,
): Promise<void> {
if (!message.attachments?.length || !cwd || !downloadFn) return;
const dir = path.join(cwd, 'raw', 'wechat', todayDir());
fs.mkdirSync(dir, { recursive: true });
for (const att of message.attachments) {
try {
const { data, contentType } = await downloadFn(att.url, att.aesKey);
const ext = att.kind === 'image'
? imageExt(contentType, data)
: path.extname(att.fileName ?? '') || 'bin';
const baseName = att.fileName
? sanitizeFileName(att.fileName)
: `${Date.now()}-${index}.${ext}`;
const outPath = path.join(dir, baseName);
fs.writeFileSync(outPath, data);
if (message.text.includes(att.url)) {
message.text = message.text.replace(att.url, outPath);
}
if (!message.text.includes(outPath)) {
message.text = `${message.text}\n[${att.kind === 'image' ? '图片' : '文件'}] 已下载到本地:${outPath}`;
}
} catch (err) {
console.log(`[weixin-download] failed: ${err.message}`);
}
}
}
设计亮点:
- 解耦:
materializeAttachments不依赖WeixinApi,只接收DownloadMediaFn,方便测试 mock。 - 优雅降级:单个附件失败不影响整体消息处理。
- 图片扩展名识别:先按
content-type,再 sniff magic bytes。
4.3 AES 解密实现
client.ts 中的 downloadMedia:
async downloadMedia(
url: string,
aesKey?: string,
timeoutMs = 60_000
): Promise<{ data: Buffer; contentType: string }> {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`Weixin media download ${res.status}`);
const cipherBytes = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get('content-type') ?? '';
if (!aesKey) return { data: cipherBytes, contentType };
const key = deriveAesKey(aesKey);
if (!key) throw new Error(`Cannot derive AES key`);
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([
decipher.update(cipherBytes),
decipher.final(),
]);
const pad = decrypted[decrypted.length - 1];
if (pad > 0 && pad <= 16) {
let valid = true;
for (let i = 0; i < pad; i++) {
if (decrypted[decrypted.length - 1 - i] !== pad) { valid = false; break; }
}
if (valid) return { data: decrypted.subarray(0, decrypted.length - pad), contentType };
}
return { data: decrypted, contentType };
}
密钥派生 deriveAesKey 兼容三种输入:
export function deriveAesKey(aesKey: string): Buffer | null {
if (/^[0-9a-fA-F]{32}$/.test(aesKey)) {
return Buffer.from(aesKey, 'hex');
}
let decoded: Buffer;
try {
decoded = Buffer.from(aesKey, 'base64');
} catch {
return null;
}
if (decoded.length === 32) {
const asHex = decoded.toString('ascii');
if (/^[0-9a-fA-F]{32}$/.test(asHex)) {
return Buffer.from(asHex, 'hex');
}
}
if (decoded.length === 16) {
return decoded;
}
return null;
}
4.4 为什么是 AES-128-ECB?
最初我也以为是 AES-128-CBC,试了 IV=0、IV=key、IV=密文头都不行。后来参考了 CowAgent(chatgpt-on-wechat 的 iLink 实现),确认是 AES-128-ECB。
验证方法:
- 用 Python
pycryptodome以 ECB 模式解密 - 图片解密后首字节为
FFD8FFE1 - PDF 解密后首字节为
255044462D312E37(%PDF-1.7) - 明文长度等于消息中的
hd_size/len
五、Prompt 适配:让 AI 知道"文件已经在了"
解密落盘后,还要解决一个问题:AI 不知道文件已经本地化了,会新建一个 .md 说"PDF 内容还没提取"。
我们在 prompt 里加了规则:
## 微信资料投递规则
daemon 已经把附件下载到 `raw/wechat/YYYY-MM-DD/`,消息里会以
`[文件] xxx.pdf (链接: <本地路径>)` 形式给出。
### A. 实体文件 / 图片
1. 不要再新建 `.md` 暂存文件
2. 直接读取本地文件内容
3. 回复时说明已暂存路径
### B. URL / 网页分享
1. 在 `raw/wechat/` 下新建 `raw/wechat/YYYY-MM-DD/HHmm-简短标题.md`
2. 记录时间、来源、链接、摘要
六、完整数据流
七、踩过的坑
| 坑 | 现象 | 修复 |
|---|---|---|
message_type !== 1 硬过滤 |
文件/图片被丢弃 | 移除过滤,依赖 item_list 判断 |
| 下载后是密文 | 文件打不开 | 实现 AES-128-ECB 解密 |
aes_key 两种编码 |
部分图片/文件解不开 | deriveAesKey 兼容 hex/base64 |
| Read 工具渲染控制字符 | 正则 \x00-\x1f 显示成 -] |
注意实际文件中是 | -] |
| 测试环境串扰 | 测试间歇失败 | 隔离 USERPROFILE |
八、测试策略
错误驱动测试,每个坑对应一个用例:
- 消息解析测试:纯文本、文件、图片、非
message_type=1的文件、无内容、缺少from_user_id - 附件提取测试:纯文本、文件图片混合、无 media URL
- 端到端解密测试:ECB 加密的 PDF/JPEG 落盘校验
- 密钥派生测试:hex、base64 hex、base64 原始字节、无效输入
全量结果:336 pass / 0 fail / 3 skip
九、架构决策
9.1 为什么"下载落盘"放在 daemon?
| 职责 | daemon | AI runtime |
|---|---|---|
| 临时 CDN URL 下载 | ✅ 网络环境稳定 | ❌ URL 可能过期 |
| AES-128-ECB 解密 | ✅ 稳定、可测 | ❌ 协议细节泄露给模型 |
| 文件命名和落盘 | ✅ 确定性逻辑 | ❌ 模型可能写错位置 |
| 读取/摘要/入库 | ❌ | ✅ 模型的本职 |
结论:daemon 负责"下载→解密→落盘→本地路径",然后停手。
9.2 为什么 media.ts 不依赖 WeixinApi?
materializeAttachments 接收 DownloadMediaFn 而不是 WeixinApi 实例:
- 可测试性:单元测试可注入 mock
- 关注点分离:
media.ts只关心"下载 URL 得到 Buffer"
十、总结
| 消息类型 | 处理方式 | 关键模块 |
|---|---|---|
| 文本 | 取 text_item.text |
message.ts |
| 公众号链接 | 识别 URL,交给 AI | message.ts + wiki-prompts.ts |
| 图片 | 下载 → AES-128-ECB 解密 → 落盘 | message.ts + media.ts + client.ts |
| 文件/PDF | 下载 → AES-128-ECB 解密 → 落盘 | message.ts + media.ts + client.ts |
| URL/网页 | AI 新建 .md 记录 |
wiki-prompts.ts |
如果你也在做微信机器人或者个人 AI 工作流,建议重点关注这三点:
- 是否处理了加密媒体
- 是否把文件落盘为本地文件
- AI 是否知道文件已经本地化
相关代码见 Molio 仓库的 apps/daemon/src/core/weixin/,欢迎交流和 PR。
github: https://github.com/zhuzhaoyun/Molio
Molio 为一个开箱即用的本地知识库创作系统,集成 obsidian + claude + 微信。
浙公网安备 33010602011771号