diff --git a/docs/wechat-ilink-refactor.md b/docs/wechat-ilink-refactor.md new file mode 100644 index 0000000..5d1306a --- /dev/null +++ b/docs/wechat-ilink-refactor.md @@ -0,0 +1,316 @@ +# 微信 Bot iLink 协议重构方案 + +> **版本**: v1.0 | **日期**: 2026-08-15 | **作者**: 七虎AI 技术团队 +> **背景**: 参考微信官方 iLink (ClawBot) 协议完整解析,对平台现有个人微信 Bot 实现做差距分析并重构 +> **关联**: `im_bot_user.py`(前台用户级)、`admin/im_bot_admin.py`(后台平台级)、`im_llm.py`(EngineCore 薄壳) + +--- + +## 一、协议背景:微信官方 iLink (ClawBot) + +### 1.1 定位 + +iLink(智联)是腾讯微信 2026 年官方开放的个人微信机器人通信协议,对外产品名 **ClawBot**。这是**唯一合规**的个人微信 Bot 通道,有《微信 ClawBot 功能使用条款》法律背书。 + +| 维度 | 逆向 iPad/PC Hook(旧灰产) | iLink Bot API(官方合规) | +|------|---------------------------|--------------------------| +| 合规性 | 违反微信用户协议 | 官方开放,签署使用条款 | +| 封号风险 | 极高,大号易永久封禁 | 正常使用无封号风险 | +| 协议稳定性 | 微信升级即失效 | 服务端 API,版本可控 | +| 登录方式 | 模拟设备/劫持会话 | 微信扫码授权,临时 token | +| 通信方式 | 模拟心跳,触发风控 | 长轮询,无需公网 IP | + +### 1.2 核心架构(三层) + +``` +上层(逻辑): 业务代码 —— 消息收发、AI 应答、工具调用 +中层(文件): CDN 媒体加解密 —— AES-128-ECB + PKCS7 +底层(通道): iLink 长轮询 —— 消息通道(ilinkai.weixin.qq.com) +``` + +两个核心域名: +| 域名 | 用途 | +|------|------| +| `https://ilinkai.weixin.qq.com` | 所有 API 请求 | +| `https://novac2c.cdn.weixin.qq.com/c2c` | 图片/语音/视频媒体文件 | + +### 1.3 七个 API 端点 + +| 端点 | 方法 | 功能 | 关键参数 | +|------|------|------|---------| +| `/ilink/bot/get_bot_qrcode` | GET | 获取登录二维码 | `?bot_type=3` | +| `/ilink/bot/get_qrcode_status` | GET | 轮询扫码状态 | `?qrcode=xxx` | +| `/ilink/bot/getupdates` | POST | 长轮询收消息(35s hold) | `{ get_updates_buf }` | +| `/ilink/bot/sendmessage` | POST | 发送消息 | `{ msg: { context_token, item_list } }` | +| `/ilink/bot/getuploadurl` | POST | 获取 CDN 上传地址 | `{ filekey, md5, len }` | +| `/ilink/bot/getconfig` | POST | 获取配置 | `{ base_info }` | +| `/ilink/bot/sendtyping` | POST | 发送"正在输入"状态 | `{ to_user_id, typing_ticket }` | + +### 1.4 鉴权机制 + +``` +Authorization: Bearer {bot_token} # 扫码登录获取 +AuthorizationType: ilink_bot_token # 固定值 +X-WECHAT-UIN: {随机uint32 → base64} # 每次请求换新,防重放攻击 +``` + +### 1.5 消息机制 + +- **长轮询**:`get_updates_buf` 是游标/位置书签,每次请求必须用返回的新值替换旧值,否则重复收消息死循环 +- **context_token**:每条消息的"身份证",回复必须原样回带,否则不关联到正确对话窗口 +- **不能主动推送**:必须由用户先发消息触发 +- **消息类型**:`type 1`文本 / `2`图片 / `3`语音 / `4`文件 / `5`视频 +- **语音自带 ASR**:VoiceItem 含 `text` 字段,微信已做好语音转文字 + +--- + +## 二、现状分析 + +### 2.1 已实现能力(im_bot_user.py) + +| 能力 | 实现 | 状态 | +|------|------|------| +| 扫码登录 | `get_bot_qrcode` + `get_qrcode_status` 轮询 | ✅ 完整 | +| 长轮询收消息 | `getupdates` + `get_updates_buf` 游标推进 | ✅ 完整 | +| 发消息 | `sendmessage` + `context_token` 回带 | ✅ 完整 | +| 鉴权 | `ilink_bot_token` + `X-WECHAT-UIN` 随机防重放 | ✅ 完整 | +| 文本分段 | `WECHAT_TEXT_MAX_LEN=1500` 超长分段 | ✅ 完整 | +| 会话过期检测 | `ret=-14/-2` 区分过期/参数错误 | ✅ 完整 | +| 用户级隔离 | 前台用户各自绑定,`im_bot_wechat_account` 按 user_id 隔离 | ✅ 完整 | +| token 持久化 | `bot_token`/`ilink_bot_id` 存 PG | ✅ 完整 | +| 对话历史 | `_log_chat` + 镜像到 AI 聊天窗 | ✅ 完整 | +| 异常日志 | 接收器异常写 `/tmp/wechat_receiver_err.log` | ✅ 完整 | +| 双 Claw 单引擎 | `im_llm.py` 薄壳复用 EngineCore | ✅ 完整 | + +### 2.2 差距分析(核心问题) + +| # | 能力 | 当前状态 | 影响 | 优先级 | +|---|------|---------|------|--------| +| 1 | **媒体内联发送** | ❌ 降级为纯文本链接(`🖼️ 图片已生成,点击查看:URL`) | 图片/视频不能内联显示,用户体验差 | 🔴 P0 | +| 2 | **sendtyping** | ❌ 未实现 | AI 回复等待期无"正在输入"提示 | 🟡 P1 | +| 3 | **语音消息** | ❌ 未提取 VoiceItem ASR text | 语音消息无法应答 | 🟡 P1 | +| 4 | **Session Guard 增强** | ⚠️ `-14` 仅标记 expired,无主动通知 | 会话过期用户无感知,需手动发现重扫 | 🟡 P1 | +| 5 | **CDN 下载解密** | ❌ 未实现(收图片/语音/文件无法解密) | 无法处理用户发来的图片/文件 | 🟢 P2 | + +### 2.3 核心痛点详述:媒体内联发送 + +**当前实现**(`_wechat_send_media`): +```python +def _wechat_send_media(...): + # iLink 对 type=2/5 媒体 item 返回 ret=-2 invalid arguments + # 降级为纯文本链接 + caption = "🖼️ 图片已生成,点击查看:" + relay_url + return _wechat_send_message(...) # 发文本 +``` + +**根本原因**:iLink 媒体消息不是直接传 URL,而是需要走 **CDN 上传加密链路**: +1. 下载 AGNES 生成的图片字节 +2. 用 `getuploadurl` 获取微信 CDN 上传地址 +3. **AES-128-ECB 加密**图片字节 +4. 上传到 `novac2c.cdn.weixin.qq.com` +5. 发消息时带 `download_param` + `aes_key`(type=2 图片 / type=5 视频) + +当前代码缺少整个 CDN 加密链路,所以只能降级为文本链接。 + +--- + +## 三、重构方案 + +### 3.1 总体设计 + +``` +用户微信 ──(文本/图片/语音)──> iLink getupdates + │ + ▼ + ┌── im_bot_user.py 接收循环 ──┐ + │ 提取 text / VoiceItem ASR │ + │ (P1) 语音转文字 │ + └──────────┬─────────────────┘ + ▼ + EngineCore._send_message_core + (双 Claw 单引擎,复用 LLM/MCP) + │ + ┌──────────────┼──────────────┐ + ▼ ▼ + 文本回复 媒体回复(__media__) + │ │ + │ ┌──────────────┴──────────────┐ + │ │ (P0) 媒体内联发送链路 │ + │ │ 1. 下载 AGNES 图片字节 │ + │ │ 2. getuploadurl 拿地址 │ + │ │ 3. AES-128-ECB 加密 │ + │ │ 4. 上传微信 CDN │ + │ │ 5. sendmessage type=2/5 │ + │ └──────────────┬──────────────┘ + ▼ ▼ + sendmessage (文本) sendmessage (媒体 item) + + (P1) sendtyping 前置 +``` + +### 3.2 分阶段实施 + +#### Phase 1(P0):媒体内联发送 —— 核心 + +**目标**:图片/视频真正内联显示在微信,替代当前文本链接降级。 + +**新增模块** `app/api/im_ilink_media.py`: +```python +""" +iLink CDN 媒体加解密 —— 参考微信官方 iLink 协议 +核心:AES-128-ECB + PKCS7 加密上传,下载解密 +""" +import hashlib, os +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding + +def _aes_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes: + """AES-128-ECB + PKCS7 加密""" + padder = padding.PKCS7(128).padder() + padded = padder.update(plaintext) + padder.finalize() + cipher = Cipher(algorithms.AES(key), modes.ECB()) + enc = cipher.encryptor() + return enc.update(padded) + enc.finalize() + +def _aes_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes: + """AES-128-ECB 解密""" + cipher = Cipher(algorithms.AES(key), modes.ECB()) + dec = cipher.decryptor() + padded = dec.update(ciphertext) + dec.finalize() + unpadder = padding.PKCS7(128).unpadder() + return unpadder.update(padded) + unpadder.finalize() + +def upload_media_to_wechat_cdn(bot_token: str, baseurl: str, media_bytes: bytes, filekey: str) -> dict: + """六步上传流程: + 1. 计算 MD5 + 2. 生成随机 AES-128 key (16字节) + 3. POST /ilink/bot/getuploadurl → upload_param + 4. AES-128-ECB 加密文件 + 5. POST novac2c.cdn.weixin.qq.com/c2c/upload + 6. 返回 download_param + aes_key + """ + md5 = hashlib.md5(media_bytes).hexdigest() + aes_key = os.urandom(16) + # ... getuploadurl 调用 + # ... 加密 + 上传 + return {"download_param": "...", "aes_key": aes_key.hex()} +``` + +**修改 `_wechat_send_media`**: +```python +def _wechat_send_media(baseurl, bot_token, to_user_id, context_token, media_type, url): + # 1. 下载 AGNES 图片字节(走 HK 代理,trust_env=False) + media_bytes = _download_media(url) + # 2. 上传微信 CDN + AES 加密 + cdn = upload_media_to_wechat_cdn(bot_token, baseurl, media_bytes, filekey) + # 3. 发媒体消息(type=2 图片 / type=5 视频) + item = { + "type": 2 if media_type == "image" else 5, + "image_item": { # 或 video_item + "media": cdn["download_param"], + "aes_key": cdn["aes_key"], + } + } + return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item]) +``` + +**依赖**:`cryptography` 库(需加入 requirements.txt) + +#### Phase 2(P1):sendtyping 正在输入 + +```python +def _wechat_send_typing(baseurl, bot_token, to_user_id, typing_ticket): + """发消息前先发'正在输入'状态,改善等待体验""" + return _post(baseurl + "/ilink/bot/sendtyping", + {"to_user_id": to_user_id, "typing_ticket": typing_ticket}) + +# 接收循环中,调用 _qihua_reply 前: +_wechat_send_typing(baseurl, bot_token, sender, typing_ticket) +``` +- `typing_ticket` 从 `getconfig` 获取,按用户缓存 24h + +#### Phase 3(P1):语音消息 ASR + +```python +def _wechat_extract_text(msg): + """扩展:支持语音消息自带 ASR 结果""" + for it in (msg.get("item_list") or []): + if it.get("type") == 1: # 文本 + ... + elif it.get("type") == 3: # 语音 + voice = it.get("voice_item", {}) + if voice.get("text"): # 微信已做好语音转文字 + parts.append(voice["text"]) + ... +``` + +#### Phase 4(P1):Session Guard 增强 + +```python +def _mark_wechat_expired(account_id): + """增强:标记 expired 的同时,主动给用户发站内通知/邮件提醒重扫""" + # 1. 标记 expired + 清 token(现有逻辑) + # 2. 停掉接收线程 + # 3. 触发站内通知:'您的微信 Bot 会话已过期,请重新扫码绑定' + # 4. 记录 audit log +``` + +#### Phase 5(P2):接收侧媒体解密 + +```python +def _download_wechat_media(msg): + """接收用户发来的图片/语音/文件: + 1. GET novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=xxx + 2. 用消息里的 aes_key 解密 + """ +``` + +### 3.3 技术要点 + +| 要点 | 说明 | +|------|------| +| AES 模式 | AES-128-ECB(微信指定,非标准 CBC;一次性随机 key + HTTPS 保证安全) | +| 密钥长度 | 16 字节(128-bit) | +| 填充 | PKCS7 | +| 密文大小 | `ceil((plaintext_size+1)/16)*16` | +| 媒体域名 | `novac2c.cdn.weixin.qq.com/c2c/upload|download` | +| 错误码 | `0`成功 / `-14`会话过期 / `-2`参数错误 | + +### 3.4 风险与规避 + +| 风险 | 规避 | +|------|------| +| ECB 模式理论不安全 | 每文件一次性随机 key + HTTPS,风险可控 | +| 媒体上传超时 | 大图/视频走异步,超时重试 + 降级文本链接兜底 | +| `cryptography` 依赖 | 需在容器 venv 安装,纳入 requirements.txt | +| iLink 媒体协议细节不明确 | 保留文本链接降级 fallback,逐步灰度 | + +--- + +## 四、实施计划 + +| Phase | 内容 | 优先级 | 预估工作量 | 依赖 | +|-------|------|--------|-----------|------| +| P0 | 媒体内联发送(CDN AES 加密) | 🔴 高 | 2-3 天 | `cryptography` 库 | +| P1 | sendtyping 正在输入 | 🟡 中 | 0.5 天 | 无 | +| P1 | 语音 ASR | 🟡 中 | 0.5 天 | 无 | +| P1 | Session Guard 增强 | 🟡 中 | 1 天 | 站内通知 | +| P2 | 接收侧媒体解密 | 🟢 低 | 2 天 | P0 经验复用 | + +--- + +## 五、验收标准 + +- [ ] P0:微信里生成的图片/视频**内联显示**,不再是文本链接 +- [ ] P1:AI 回复前微信显示"正在输入" +- [ ] P1:发语音消息能正确应答(ASR 文本进入 LLM) +- [ ] P1:会话过期时用户收到明确重扫通知 +- [ ] 双 Claw 单引擎不回归(`tests/test_dual_claw.py` 绿) +- [ ] pytest + vitest 全绿 + +--- + +## 六、参考资料 + +- 微信官方 iLink (ClawBot) 协议(CSDN: 2401_88147261 / HiWangWenBing / bytenote) +- OpenClaw 框架 `@tencent-weixin/openclaw-weixin` 增强:Session Guard / Config Cache / SILK 转码 / CDN 加解密 +- 平台现状代码:`app/api/im_bot_user.py`、`app/api/admin/im_bot_admin.py`、`app/api/im_llm.py`