微信机器人接 iLink 踩坑:图片/文件是加密的,AI 看不到

接入微信 iLink/ClawBot 后,我发现微信消息远不止文本。图片、PDF 等媒体文件走的是 AES-128-ECB 加密 CDN,daemon 必须先解密、落盘,再交给 AI 处理。本文完整记录 Molio 的实现链路、踩坑过程与测试策略,相关代码位于 apps/daemon/src/core/weixin/

一、背景:为什么不是"拿到 URL 就能用"

我做了一个本地 AI 知识操作系统 Molio,其中一个核心能力是把微信消息接入 AI 工作流。接入微信 iLink/ClawBot 后,我原以为消息处理就是:

  1. getUpdates 拿消息
  2. 提取文本
  3. 把文本交给 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 字符串,如 24dae86aeb24d7a2069b7b852dec5bc3
  • media.aes_key:base64 编码的 hex 字符串

两种都必须兼容处理。

四、完整实现:从解析到落盘

4.1 提取附件

extractAttachmentsfile_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.aeskeymedia.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}`);
    }
  }
}

设计亮点:

  1. 解耦materializeAttachments 不依赖 WeixinApi,只接收 DownloadMediaFn,方便测试 mock。
  2. 优雅降级:单个附件失败不影响整体消息处理。
  3. 图片扩展名识别:先按 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

验证方法:

  1. 用 Python pycryptodome 以 ECB 模式解密
  2. 图片解密后首字节为 FFD8FFE1
  3. PDF 解密后首字节为 255044462D312E37%PDF-1.7
  4. 明文长度等于消息中的 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. 记录时间、来源、链接、摘要

六、完整数据流

flowchart TD A[微信服务器] -->|POST /ilink/bot/getupdates| B[WeixinService.pollLoop] B --> C[handleRawMessage] C --> D[去重<br/>message_id 7h窗口] D --> E[parseWeixinMessage] E --> F[itemText<br/>提取文本] E --> G[extractAttachments<br/>提取附件] F --> H[createMolioRun] G --> H H --> I[materializeAttachments] I -->|downloadFn| J[下载加密文件<br/>from media.full_url] J --> K[deriveAesKey] K --> L[AES-128-ECB 解密] L --> M[落盘<br/>raw/wechat/YYYY-MM-DD/] M --> N[替换 message.text 中<br/>URL 为本地路径] N --> O[AI Runtime<br/>读取本地文件] O --> P[按 Wiki Prompt 规则<br/>处理 / 摘要 / 入库]

七、踩过的坑

现象 修复
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 实例:

  1. 可测试性:单元测试可注入 mock
  2. 关注点分离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 工作流,建议重点关注这三点:

  1. 是否处理了加密媒体
  2. 是否把文件落盘为本地文件
  3. AI 是否知道文件已经本地化

相关代码见 Molio 仓库的 apps/daemon/src/core/weixin/,欢迎交流和 PR。
github: https://github.com/zhuzhaoyun/Molio
Molio 为一个开箱即用的本地知识库创作系统,集成 obsidian + claude + 微信。

posted on 2026-06-23 16:43  张居斜  阅读(0)  评论(0)    收藏  举报

导航