docs: weixin ClawBot bridge protocol and examples

This commit is contained in:
AskaEth committed 2026-08-21 18:15:20 +08:00
commit 4ea3957da9
13 files changed
+2158

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# weixin_ClawBot_Bridge — 微信 ClawBot / iLink 协议开发参考文档
> 目的:把「微信 ClawBot 插件 + iLink Bot API」的公开技术资料整理成一套可直接用于开发的参考文档,供本机其他 AI 项目(不经过 OpenClaw)对接微信时查阅。
>
> 整理日期:2026-08-19。资料来源均为公开逆向分析与社区实现(见各文档末尾「参考资料」),**不是腾讯官方完整文档**。协议细节可能随版本变化,关键行为请以实测为准。
## 一句话结论
微信 ClawBot 底层是腾讯官方的 **iLink Bot API**(`https://ilinkai.weixin.qq.com`,纯 HTTP/JSON + 长轮询),协议本身与 OpenClaw 框架**无耦合**。任何能发 HTTP 请求的程序都可以直接对接,写一个几百行的「转接器 / Bridge」即可把微信消息接到任意 AI 后端。
## 文档导航
| 文档 | 内容 | 什么时候读 |
|---|---|---|
| [docs/01-overview.md](docs/01-overview.md) | 背景、ClawBot / iLink / OpenClaw 三者关系、合规性、能力边界总览 | 先读这份,建立全局认识 |
| [docs/02-ilink-protocol.md](docs/02-ilink-protocol.md) | iLink 协议完整参考:全部端点、鉴权、扫码登录、长轮询、消息结构、context_token、错误码与重试 | 写协议层代码时对照查 |
| [docs/03-messages-and-media.md](docs/03-messages-and-media.md) | 消息类型详解、媒体文件 CDN 上传/下载、AES-128-ECB 加解密、语音/图片/文件/视频处理 | 需要收发图片/语音/文件时读 |
| [docs/04-bridge-design.md](docs/04-bridge-design.md) | 转接器架构设计:模块划分、会话管理、状态持久化、与任意 AI 后端的对接模式、可靠性设计 | 动手设计自己的 Bridge 前读 |
| [docs/05-implementation-guide.md](docs/05-implementation-guide.md) | 最小可运行实现(伪代码 + Node.js / Python 骨架)、常见坑、参考项目清单 | 直接照着重型实现 |
| [docs/06-limits-and-faq.md](docs/06-limits-and-faq.md) | 平台限制、官方条款要点、未知/未证实事项、FAQ | 评估能不能做某功能、踩坑时查 |
## 速查卡片
- 接入域名:`https://ilinkai.weixin.qq.com`
- CDN 域名:`https://novac2c.cdn.weixin.qq.com/c2c`
- 核心端点:`get_bot_qrcode` / `get_qrcode_status`(登录)、`getupdates`(长轮询收)、`sendmessage`(发)、`getconfig` / `sendtyping`(输入中)、`getuploadurl`(媒体上传)
- 鉴权:`Authorization: Bearer <bot_token>`,token 由扫码登录获得,长期有效
- 三个最容易踩的坑:
1. `get_updates_buf` 游标必须持久化并每次更新,否则重复收消息
2. 回复必须带原消息的 `context_token`,否则关联不到对话
3. 媒体文件全部是 AES-128-ECB 加密传输,密钥处理方式见 03 文档
## 典型接入架构(Bridge 模式)
```
微信用户 ⇄ 微信客户端 ⇄ iLink 服务器 (ilinkai.weixin.qq.com)
⇕ HTTP 长轮询
┌──────────────────┐
│ 你的 Bridge │
│ - iLink 客户端 │
│ - 消息规范化 │
│ - 会话/游标管理 │
└────────┬─────────┘
⇕ 你项目自己的 API
┌──────────────────┐
│ 你的 AI 项目 │
│ (LLM/Agent/工作流)│
└──────────────────┘
```
## 多语言示例(examples/)
[examples/](examples/) 下提供 Python / Go / Java / C / C++ 五份**单文件、可独立运行**的最小示例,各自覆盖同一套完整流程:
```
扫码登录(get_bot_qrcode / get_qrcode_status) → 长轮询收消息(getupdates) → 回声回复(sendmessage)
```
每份示例都包含:`bot_token` / 游标持久化、`context_token` 透传、`errcode=-14` 过期处理、2s→30s 退避重试、长文本分片发送。接入你自己的 AI 项目只需替换示例中的 `askYourAI`(回声占位)一个函数。
| 语言 | 文件 | 依赖 | 运行 | 验证状态 |
|---|---|---|---|---|
| Python | [examples/python/ilink_bridge.py](examples/python/ilink_bridge.py) | `requests` | `python3 ilink_bridge.py` | ✅ 语法检查通过 |
| Go | [examples/go/main.go](examples/go/main.go) | 仅标准库 | `go run main.go` | ✅ `go build` 通过 |
| Java | [examples/java/IlinkBridge.java](examples/java/IlinkBridge.java) | JDK 11+ 零依赖 | `javac IlinkBridge.java && java IlinkBridge` | ✅ `javac` 通过 |
| C | [examples/c/ilink_client.c](examples/c/ilink_client.c) | libcurl | `gcc ilink_client.c -o ilink -lcurl && ./ilink` | ✅ 语法检查通过(本机无 curl 开发头,未做链接验证) |
| C++ | [examples/cpp/ilink_client.cpp](examples/cpp/ilink_client.cpp) | libcurl | `g++ -std=c++17 ilink_client.cpp -o ilink -lcurl && ./ilink` | ✅ 同上 |
使用注意:
- 首次运行会打印二维码链接,微信扫码确认后凭据写入同目录 `bridge-state.json`(权限 600,**勿提交 git**);之后重启免扫码
- Java / C / C++ 三份为保持零额外依赖,JSON 解析用的是朴素的字符串/正则提取,**仅适合演示**;正式项目请换 Jackson / cJSON / nlohmann-json,并按 `msgs[]` 完整解析
- 所有示例只实现了文本收发;图片/语音/文件的 AES-128-ECB + CDN 链路见 [docs/03-messages-and-media.md](docs/03-messages-and-media.md)
更多约定与省略说明见 [examples/README.md](examples/README.md)。
## 参考项目(可直接借鉴)
| 项目 | 语言 | 说明 |
|---|---|---|
| [hao-ji-xing/openclaw-weixin](https://github.com/hao-ji-xing/openclaw-weixin) | Node.js | 协议逆向分析 + 裸调 Demo(`demo.mjs`) |
| [hao-ji-xing/cc-weixin](https://github.com/hao-ji-xing/cc-weixin) | Node.js | 把 Claude Code Agent 接入微信的完整示例 |
| [openclaw-java/openclaw-weixin-sdk](https://github.com/openclaw-java/openclaw-weixin-sdk) | Java | 完整 SDK + CLI + Web Terminal,错误恢复策略写得很细 |
| [openakita/openakita](https://github.com/openakita/openakita) | Python | 多 IM 平台适配器架构,微信适配器直接调 iLink 原生 API |
| [@tencent-weixin/openclaw-weixin](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) | TypeScript | 腾讯官方协议实现包(41 个源文件,最权威的协议参考) |
+98
View File
@@ -0,0 +1,98 @@
# 01 — 概述:ClawBot、iLink、OpenClaw 是什么关系
## 1. 背景
2026 年 3 月,微信上线了官方插件 **微信 ClawBot**(入口:微信 → 我 → 设置 → 插件 → 微信 ClawBot),这是微信**首次官方开放个人账号的 Bot API**。在此之前,程序控制微信只有灰色手段:
| 方式 | 典型实现 | 性质 |
|---|---|---|
| 逆向 iPad/网页协议 | WeChatPadPro、itchat(-uos) | 违反用户协议,随时封号 |
| PC 客户端 Hook | DLL 注入、内存读写 | 违法风险,高封号率 |
| 企业微信 API | 官方开放 | 合法,但面向企业不是个人微信 |
| **ClawBot / iLink** | 微信内置官方插件 | **官方产品,有专项使用条款背书** |
腾讯为此发布了《微信 ClawBot 功能使用条款》(签订地深圳南山,适用中国大陆法律)。条款明确腾讯的定位:**只提供消息收发通道,不存储用户输入/输出内容,不提供 AI 服务**——AI 能力完全由开发者自己接。
## 2. 三个概念的分工
| 名词 | 是什么 | 谁的产品 |
|---|---|---|
| **ClawBot** | 微信客户端内置插件,用户在微信里看到的 AI 联系人(带 "AI" 标识的普通聊天窗口) | 腾讯 |
| **iLink(智联)** | Bot 通信协议,HTTP/JSON + 长轮询,服务端 `ilinkai.weixin.qq.com` | 腾讯 |
| **OpenClaw** | 开源 AI Agent 框架(2025-11 首发,昵称"小龙虾"),腾讯官方通道的默认宿主 | 第三方开源社区(非腾讯) |
关系链条:
```
用户 → 微信客户端(ClawBot 插件) → iLink 服务器 → Bot 后端(默认是 OpenClaw,可以是任何东西)
```
腾讯发布的 npm 包:
- `@tencent-weixin/openclaw-weixin-cli` — 一键安装工具(检测 openclaw → 装插件 → 引导扫码 → 重启 gateway)
- `@tencent-weixin/openclaw-weixin` — 真正的协议实现包(TypeScript,约 41 个源文件:`auth/`、`api/`、`cdn/`、`messaging/`、`monitor/`、`config/`、`storage/`)
## 3. 关键事实:iLink 不绑定 OpenClaw
**iLink 是标准 HTTP/JSON API,与 OpenClaw 框架零耦合。** 官方的 npm 包只是协议的一个封装实现。任何语言、任何进程都可以直接调 iLink:
- 无需公网 IP(长轮询,主动外连)
- 无需 SDK(`fetch` / `requests` 即可)
- 无需安装 OpenClaw
已有的非 OpenClaw 实践:
- [cc-weixin](https://github.com/hao-ji-xing/cc-weixin):把 Claude Code Agent 接进微信
- [OpenAkita](https://github.com/openakita/openakita/blob/main/docs/im-channels.md):Python Agent 框架的微信适配器,直调 iLink 原生 API
- [openclaw-weixin-sdk](https://github.com/openclaw-java/openclaw-weixin-sdk):Java SDK + CLI + Web Terminal
> 协议跟随情况:2026-04 官方包已迭代到 2.1.x,但 iLink HTTP 协议本体(`getupdates` / `sendmessage` / `getuploadurl` / AES-128-ECB / CDN)未变,第三方适配器无需改动。
## 4. 能力边界总览
下表合并了多个来源的观察,**存在版本差异和放量灰度,标注 ⚠️ 的项建议实测确认**:
| 项 | 状态 | 备注 |
|---|---|---|
| 私聊(1 对 1) | ✅ 支持 | 主要形态 |
| 群聊 | ⚠️ 未开放/存疑 | 协议源码有 `group_id` 字段,但多数资料称群聊未开放 |
| 文本收发 | ✅ | 微信侧不支持 Markdown 渲染,发送时应转纯文本 |
| 图片收发 | ✅ | CDN + AES-128-ECB 加密 |
| 语音 | ⚠️ 入站 ✅ / 出站存疑 | 入站 silk 编码且附 ASR 转写文本;有资料称 bot 暂不能主动发语音,也有适配器实现了语音上传 |
| 文件/视频 | ⚠️ 协议支持 | item type 有定义,CDN 链路完整;早期资料称"仅文本",后续适配器已实现 |
| 主动推送 | ⚠️ 受限 | 需携带入站消息返回的 `context_token`;有资料称超过 24h 窗口后送达不到 |
| 历史消息拉取 | ❌ | 只有游标机制,无历史 API |
| 一个微信号绑定 bot 数 | 1 个 | 一个微信号 = 一个 bot 实例 |
| 客户端版本要求 | iOS ≥ 8.0.70 | 有资料称安卓 ≥ 8.0.70、鸿蒙 ≥ 8.0.16.40 也已支持 ⚠️ |
| 开放状态 | 逐步放量 | 不是所有账号都能看到插件入口 |
## 5. 合规要点(详细条款解读见 06 文档)
- 这是官方通道,正常使用无封号风险——这是它相对 itchat/Wechaty 的核心价值。
- 腾讯保留限速、内容识别、拦截、终止连接的权利(条款 4.7)。
- 腾讯不存储消息内容,但收集 IP、操作日志、设备信息用于安全审计(条款 5.3)。
- 禁止用于绕过微信技术保护、违法违规、危害网络安全等用途(条款 4.6)。
- 腾讯可随时变更/终止服务(条款 7.2)——**核心业务不要单点依赖它,设计降级方案**。
## 6. 与其他 IM 通道的对比(选型参考)
如果你的 AI 项目不只接微信,可以横向比较(数据来自 OpenAkita 的多平台实践):
| 平台 | 接收方式 | 需要公网 IP | 群聊 | 合法性 |
|---|---|---|---|---|
| 微信个人号(iLink) | HTTP 长轮询 | 否 | ⚠️ 未开放 | 官方 |
| Telegram | 长轮询 / Webhook | 否 | ✅ | 官方 |
| 飞书 | WebSocket 长连接 | 否 | ✅ | 官方 |
| 钉钉 | Stream (WebSocket) | 否 | ✅ | 官方 |
| 企业微信 | HTTP 回调 | **是** | ✅ (@机器人) | 官方 |
| QQ 官方机器人 | WebSocket | 否 | ✅ | 官方 |
微信 iLink 的独特优势:用户在**自己的微信主界面**直接和 AI 聊,零安装、零跳转,体验最接近"真人联系人"。独特劣势:仅私聊、主动消息受限、灰度放量。
## 参考资料
- [微信 Bot API 技术解析:腾讯 iLink 协议首次合法开放](https://github.com/hao-ji-xing/openclaw-weixin/blob/main/weixin-bot-api.md)
- [WeChat ClawBot iLink 协议解析 — Charles Li](https://www.chihao.li/blog/zh/wechat-clawbot-ilink-protocol)
- [微信 ClawBot 是什么?OpenClaw 接入微信完整解读](https://xiaoman.lovetalkon.com/blog/openclaw-wechat-ai)
- [OpenAkita IM 通道集成指南](https://github.com/openakita/openakita/blob/main/docs/im-channels.md)
- [openclaw-weixin-sdk (Java) README](https://github.com/openclaw-java/openclaw-weixin-sdk)
+256
View File
@@ -0,0 +1,256 @@
# 02 — iLink 协议完整参考
> 本文档是对 iLink Bot API 的完整整理,供实现协议层时对照查阅。
> 来源:腾讯官方包 `@tencent-weixin/openclaw-weixin` 的逆向分析、Java SDK 实现、多个第三方适配器实践。字段级细节以实测为准。
## 1. 基本信息
| 项 | 值 |
|---|---|
| API 根地址 | `https://ilinkai.weixin.qq.com` |
| CDN 地址 | `https://novac2c.cdn.weixin.qq.com/c2c` |
| 协议 | HTTPS + JSON |
| 收消息模式 | HTTP 长轮询(无 Webhook,无需公网 IP) |
| 鉴权 | `Authorization: Bearer <bot_token>` |
## 2. 端点总表
| Endpoint | Method | 功能 | 需要鉴权 |
|---|---|---|---|
| `/ilink/bot/get_bot_qrcode` | GET | 获取登录二维码(`?bot_type=3`) | 否 |
| `/ilink/bot/get_qrcode_status` | GET | 轮询扫码状态(`?qrcode=xxx`) | 否 |
| `/ilink/bot/getupdates` | POST | 长轮询收消息(核心) | 是 |
| `/ilink/bot/sendmessage` | POST | 发送消息(文本/图片/文件/视频/语音) | 是 |
| `/ilink/bot/getconfig` | POST | 获取配置(typing_ticket 等) | 是 |
| `/ilink/bot/sendtyping` | POST | 发送「正在输入」状态 | 是 |
| `/ilink/bot/getuploadurl` | POST | 获取 CDN 预签名上传地址 | 是 |
## 3. 通用请求头
登录后所有请求的固定头:
```http
Content-Type: application/json
AuthorizationType: ilink_bot_token
Authorization: Bearer <bot_token>
X-WECHAT-UIN: <base64(十进制字符串(随机uint32))>
```
- `X-WECHAT-UIN`:每次请求生成一个随机 uint32 → 转十进制字符串 → base64 编码。每次都不一样,起防重放作用。
- 扫码登录阶段(`get_qrcode_status`)有实现带 `iLink-App-ClientVersion: 1` 头。
## 4. 登录流程(一次性扫码绑定)
### 4.1 时序
```
开发者 iLink 服务器 微信用户
│── GET get_bot_qrcode ──▶│ │
│◀── { qrcode, url } ─────│ │
│ │◀──── 用户扫码 ───────│
│── GET get_qrcode_status ─▶│ (长轮询/定时轮询) │
│◀── { status:"confirmed", │ │
│ bot_token, ... } ──│ │
│
▼ 持久化 bot_token,后续所有请求 Bearer 鉴权
```
### 4.2 获取二维码
```http
GET /ilink/bot/get_bot_qrcode?bot_type=3
```
响应:
```json
{
"qrcode": "xxx",
"qrcode_img_content": "https://liteapp.weixin.qq.com/q/..."
}
```
- `qrcode`:QR 标识符,轮询状态时用
- `qrcode_img_content`:二维码图片 URL,可在终端渲染(如 `qrcode-terminal`)或浏览器打开给用户扫
- `bot_type=3` 是官方包源码中硬编码的值,含义未公开,照抄即可
### 4.3 轮询扫码状态
```http
GET /ilink/bot/get_qrcode_status?qrcode=<qrcode>
```
状态流转:`wait` → `scaned` → `confirmed`(注意官方拼写是 `scaned`)。
确认后响应(关键凭据):
```json
{
"status": "confirmed",
"bot_token": "44b9b8ec@im.bot:060000b7...",
"ilink_bot_id": "44b9b8ec@im.bot",
"ilink_user_id": "o9cq809L...@im.wechat",
"baseurl": "https://ilinkai.weixin.qq.com"
}
```
- **`bot_token` 是之后所有 API 调用的鉴权凭据,一次扫码长期有效**(具体有效期官方未公布,社区观察为长期;过期会收到 errcode=-14,需重新扫码)
- 建议把 `bot_token`、`ilink_bot_id`、`ilink_user_id`、`baseurl` 全部持久化
- 轮询建议间隔 1s,整体超时给 5~8 分钟
## 5. 收消息:长轮询
### 5.1 请求
```http
POST /ilink/bot/getupdates
```
```json
{
"get_updates_buf": "<上次返回的游标,首次为空字符串>",
"base_info": { "channel_version": "1.0.2" }
}
```
### 5.2 响应
```json
{
"ret": 0,
"msgs": [ /* WeixinMessage[],见第 7 节 */ ],
"get_updates_buf": "Ck1...",
"longpolling_timeout_ms": 35000
}
```
### 5.3 机制要点
- 服务器 **hold 住连接最多约 30~35 秒**(`longpolling_timeout_ms` 会告知),有新消息立即返回,无消息超时返回空 `msgs`
- HTTP 客户端超时必须设得比 `longpolling_timeout_ms` 大(建议 +10s 余量)
- **`get_updates_buf` 是同步游标**(类似 Telegram getUpdates 的 offset):
- 每次请求带上次返回的值
- **收到响应后立刻持久化**,否则重启后会重复收消息
- 返回中没有该字段时保持旧值
- HTTP 超时/网络错误视为「本轮无新消息」,直接重试,不要中断循环
### 5.4 失败恢复与退避(来自 Java SDK 的实战策略)
| 情况 | 处理 |
|---|---|
| `errcode = -14`(会话过期 / token 失效) | 该账号进入暂停窗口(SDK 默认 1 小时),期间停止轮询和发送;提示用户重新扫码 |
| 其他失败 | 2s 后快速重试;连续失败 3 次后进入 30s 退避 |
| HTTP 超时 | 视为无新消息,立即进入下一轮 |
| 响应带 `longpolling_timeout_ms` | 动态调整下一轮轮询超时 |
## 6. 发消息
```http
POST /ilink/bot/sendmessage
```
```json
{
"msg": {
"to_user_id": "o9cq809L...@im.wechat",
"client_id": "unique-id-123",
"message_type": 2,
"message_state": 2,
"context_token": "CK3E...",
"item_list": [
{ "type": 1, "text_item": { "text": "你好!我是你的 AI。" } }
]
}
}
```
字段说明:
| 字段 | 说明 |
|---|---|
| `to_user_id` | 收件人,取入站消息的 `from_user_id` |
| `client_id` | 客户端生成的去重 ID(如 UUID),防止重发 |
| `message_type` | `2` = Bot 发出的消息(入站用户消息是 `1`) |
| `message_state` | `2` = FINISH 完成态(非流式)。协议还有 NEW(0?) → GENERATING → FINISH 的三段式状态,可用于流式分片发送(Java SDK 的 `sendTextStream` 即分片发送,但注意**那不是 LLM token 流**,而是把长文本切成多条消息) |
| `context_token` | **必填**,从要回复的那条入站消息原样取出 |
| `item_list` | 消息体,文本见上;媒体 item 构造见 03 文档 |
## 7. 消息结构(WeixinMessage)
入站消息核心字段:
```json
{
"from_user_id": "o9cq800kum_xxx@im.wechat",
"to_user_id": "e06c1ceea05e@im.bot",
"message_type": 1,
"message_state": 2,
"context_token": "AARzJWAFAAABAAAAAAAp...",
"item_list": [
{ "type": 1, "text_item": { "text": "你好" } }
]
}
```
### 7.1 ID 格式规律
- 用户 ID:`xxx@im.wechat`
- Bot ID:`xxx@im.bot`
### 7.2 常量表
| 字段 | 值 | 含义 |
|---|---|---|
| `message_type` | 1 | 用户消息(入站) |
| `message_type` | 2 | Bot 消息(出站) |
| `message_state` | 2 | FINISH(完整消息) |
| `item.type` | 1 | 文本(`text_item.text`) |
| `item.type` | 2 | 图片(CDN 加密存储) |
| `item.type` | 3 | 语音(silk 编码,`voice_item.text` 附 ASR 转写文字) |
| `item.type` | 4 | 文件附件 |
| `item.type` | 5 | 视频 |
> 媒体类 item 的详细字段(CDN 引用、aes_key 等)见 [03-messages-and-media.md](03-messages-and-media.md)。
## 8. context_token:对话关联的核心
整个协议里最关键、最容易踩坑的细节:
1. **每条入站消息都带 `context_token`**,回复时必须原样带上,否则消息无法关联到正确的对话窗口
2. 每条入站消息都应刷新本地保存的 token(Java SDK 的做法:`FileContextTokenStore` 按对话持久化,每条入站消息更新),这样主动发消息时也有最新 token 可用
3. 有资料称:**用户最后一条消息超过 24 小时后,bot 的消息可能送达不到**;在窗口内可用 `context_token` 主动追发消息。做定时推送类功能时必须考虑这个窗口(⚠️ 建议实测确认)
## 9. 输入中状态(typing)
```
POST /ilink/bot/getconfig → 获取 typing_ticket
POST /ilink/bot/sendtyping → 发送「正在输入」
```
用途:AI 后端处理耗时较长时,先发个 typing 让用户知道 bot 在干活。非必需,第一版可以不做。
## 10. 错误处理备忘
| 现象 | 含义 | 处理 |
|---|---|---|
| `errcode = -14` | 会话/token 过期 | 暂停轮询与发送,走重新扫码流程 |
| `ret != 0` | 业务错误 | 记录完整响应,按退避策略重试 |
| HTTP 层超时 | 长轮询正常超时或网络抖动 | 直接重试 |
| 重复收到同一条消息 | `get_updates_buf` 没更新/没持久化 | 检查游标保存逻辑;应用层按消息 ID 去重兜底 |
## 11. 协议层 Checklist(实现自查)
- [ ] 扫码登录:`get_bot_qrcode` → 渲染二维码 → 轮询 `get_qrcode_status` 到 `confirmed`
- [ ] 持久化:`bot_token` / `ilink_bot_id` / `ilink_user_id` / `baseurl` / `get_updates_buf` / 每对话 `context_token`
- [ ] 请求头:`Authorization`、`AuthorizationType`、随机 `X-WECHAT-UIN`
- [ ] 长轮询循环:超时 > 服务端 timeout、游标即时保存、异常不中断
- [ ] 回复带 `context_token`、带唯一 `client_id`
- [ ] errcode=-14 的暂停与重新登录路径
- [ ] 退避重试策略
## 参考资料
- [微信 Bot API 技术解析(含裸调 demo.mjs)](https://github.com/hao-ji-xing/openclaw-weixin/blob/main/weixin-bot-api.md)
- [WeChat ClawBot iLink 协议解析](https://www.chihao.li/blog/zh/wechat-clawbot-ilink-protocol)
- [openclaw-weixin-sdk (Java) — 失败恢复与退避策略一节](https://github.com/openclaw-java/openclaw-weixin-sdk)
- [OpenAkita 微信适配器说明](https://github.com/openakita/openakita/blob/main/docs/im-channels.md)
+160
View File
@@ -0,0 +1,160 @@
# 03 — 消息与媒体处理
> 本文档覆盖:五种消息类型的结构、媒体文件的 CDN 传输、AES-128-ECB 加解密、语音的特殊性。
> 媒体部分的字段级细节来自官方包逆向与第三方适配器实现,标注 ⚠️ 处建议对照官方包源码或实测确认。
## 1. 消息类型速览
| `item.type` | 类型 | 入站 | 出站 | 关键字段 |
|---|---|---|---|---|
| 1 | 文本 | ✅ | ✅ | `text_item.text` |
| 2 | 图片 | ✅ | ✅ | CDN 引用 + aes_key |
| 3 | 语音 | ✅(附转写) | ⚠️ 存疑 | silk 编码,`voice_item.text` 为 ASR 文本 |
| 4 | 文件 | ✅ | ✅ | CDN 引用 + aes_key |
| 5 | 视频 | ✅ | ⚠️ | CDN 引用 + aes_key |
> 关于出站语音/视频:早期协议解析称「仅文本」或「bot 暂不能主动发语音」,但 OpenAkita 等适配器后续实现了图片/文件/语音的 CDN+AES 出站。说明协议能力在逐步开放或官方未完整公开,**以你的账号实测为准**。
## 2. 文本消息
### 2.1 入站
```json
{
"item_list": [
{ "type": 1, "text_item": { "text": "你好" } }
]
}
```
一条消息的 `item_list` 可能含多个 item(图文混排等场景),处理时应遍历而不是只取 `[0]`。
### 2.2 出站
```json
{
"msg": {
"to_user_id": "...@im.wechat",
"client_id": "uuid",
"message_type": 2,
"message_state": 2,
"context_token": "...",
"item_list": [
{ "type": 1, "text_item": { "text": "回复内容" } }
]
}
}
```
注意:
- **微信侧不渲染 Markdown**。如果你的 AI 后端输出 Markdown,发送前应转纯文本(OpenAkita 的做法),或至少把 `**`、`` ` ``、标题符号等剥掉
- 超长文本考虑分片发送(多条 `sendmessage`)。Java SDK 提供 `sendTextStream(...)` 做分片——注意这只是「把长文本切成多条消息」,不是 LLM 流式输出
- 每条消息带唯一 `client_id` 防重
## 3. 媒体传输总流程
微信侧所有媒体文件不直接走 iLink API 的 JSON body,而是走独立 CDN:
```
CDN 域名: https://novac2c.cdn.weixin.qq.com/c2c
加密方式: AES-128-ECB
```
### 3.1 出站(Bot → 用户)完整流程
```
1. 生成随机 AES-128 key(16 字节)
2. plaintext = 原始文件字节
3. encrypted = AES-128-ECB-Encrypt(plaintext, key)
4. POST /ilink/bot/getuploadurl → 拿到 CDN 预签名上传 URL
5. PUT encrypted 到 CDN URL
6. POST /ilink/bot/sendmessage,item 中携带:
- CDN 引用参数(上传后返回/约定的 key)
- aes_key(base64 编码的 16 字节 key),供接收方解密
```
### 3.2 入站(用户 → Bot)完整流程
```
1. getupdates 收到媒体类 item(含 CDN 引用与密钥信息)
2. 从 CDN 下载 encrypted 字节
3. plaintext = AES-128-ECB-Decrypt(encrypted, key)
4. 交给业务层(存盘 / 送多模态模型 / ASR 等)
```
### 3.3 关于解密密钥的来源 ⚠️
- **出站**:key 由发送方(你)随机生成,随消息的 `aes_key` 字段(base64)带给接收方——这部分明确。
- **入站**:OpenAkita 的实现笔记记载「加密密钥从 Token 中提取(base64 解码后取后 16 字节)」。即入站媒体解密密钥与 `bot_token` 派生相关。⚠️ 此细节来源单一,实现时若解密失败,请对照 `@tencent-weixin/openclaw-weixin` 官方包 `src/cdn/` 目录的源码确认 key 的实际派生方式。
### 3.4 AES-128-ECB 实现要点
- ECB 模式无 IV,块大小 16 字节,需要 PKCS#7 padding(具体 padding 方案以官方包源码为准)
- 各语言实现:
- Node.js:`crypto.createCipheriv('aes-128-ecb', key, null)`
- Python:`Crypto.Cipher.AES.new(key, AES.MODE_ECB)`(pycryptodome)
- Java:`Cipher.getInstance("AES/ECB/PKCS5Padding")`
## 4. 语音消息的特殊性
- 入站语音是 **silk 编码**(微信语音专用格式)
- 好消息:`voice_item.text` 直接附带微信侧的 **ASR 转写文字**——如果你的场景只需要文本,直接用转写结果,不必自己解码 silk
- 如果需要原始音频做二次处理(自己的 ASR、声纹等),需 silk 解码。社区方案:
- `pilk`(Python silk 解码绑定)
- 或 silk → wav → ffmpeg 转任意格式
- 出站语音:⚠️ 开放状态存疑(见第 1 节)
## 5. 图片消息
- 入站:CDN 下载 + AES 解密得到原图(jpg/png 等),可直接送多模态模型
- 出站:按 3.1 流程加密上传
- OpenAkita 的实践经验:图片入站后由消息网关自动下载预处理;文件和视频**不自动下载**,由 Agent 按需主动拉取(节省带宽和存储)——这个策略值得借鉴
## 6. 文件与视频
- item type 4(文件)、5(视频),链路同为 CDN + AES
- 建议默认不自动下载大文件,在消息里记录元信息(文件名、大小、CDN 引用),业务层需要时再拉
- 注意给用户反馈:大文件处理慢时先发一条文本「收到文件 xxx,处理中」
## 7. 消息规范化建议(Bridge 内部模型)
不管后端是什么 AI 项目,建议在 Bridge 层把 iLink 消息规范化成统一内部模型,例如:
```json
{
"channel": "wechat-ilink",
"msg_id": "<client_id 或服务端 id>",
"direction": "inbound",
"peer_id": "o9cq809L...@im.wechat",
"context_token": "CK3E...",
"received_at": "2026-08-19T10:00:00Z",
"parts": [
{ "kind": "text", "text": "看这个图" },
{ "kind": "image", "cdn_ref": "...", "local_path": "/tmp/xxx.jpg", "mime": "image/jpeg" },
{ "kind": "voice", "transcript": "语音转写文字", "duration_ms": 3200 }
]
}
```
好处:
- AI 后端不感知 iLink 协议细节,以后加别的 IM 通道(Telegram/飞书等)复用同一模型
- 多 item(图文混排)天然支持
- 媒体下载策略(立即/惰性)在 Bridge 层统一控制
## 8. 常见坑
1. **只取 `item_list[0]`** → 图文混排丢内容。遍历整个 list。
2. **Markdown 直接发** → 用户看到一堆 `*` 和 `#`。发送前转纯文本。
3. **长文本一条发** → 可能被截断或体验差。按字数/段落分片,带不同 `client_id`。
4. **入站媒体密钥搞错** → 解密出一堆乱码还以为是 bug。先确认 key 派生方式(见 3.3)。
5. **语音自己跑 ASR** → 浪费算力,`voice_item.text` 已有微信转写。
6. **大文件自动下载** → 用户发个 100MB 视频你的磁盘/带宽就炸了。惰性下载 + 大小上限。
## 参考资料
- [微信 Bot API 技术解析 — 3.6 媒体文件 AES-128-ECB](https://github.com/hao-ji-xing/openclaw-weixin/blob/main/weixin-bot-api.md)
- [OpenAkita IM 通道指南 — 微信媒体处理与收发矩阵](https://github.com/openakita/openakita/blob/main/docs/im-channels.md)
- [openclaw-weixin-sdk (Java) — MediaUploadService / CdnUploader / CdnMediaDownloader](https://github.com/openclaw-java/openclaw-weixin-sdk)
- 腾讯官方协议包 `src/cdn/` 目录(字段细节的最终依据):`npm pack @tencent-weixin/openclaw-weixin` 下载查看
+169
View File
@@ -0,0 +1,169 @@
# 04 — Bridge(转接器)架构设计指南
> 目标读者:要把微信 ClawBot 接到自己已有 AI 项目的开发者。
> 本文档不绑定具体语言,给出模块划分、状态模型、对接模式和可靠性设计。
## 1. Bridge 的职责边界
Bridge 是一个**薄适配层**,职责只有:
```
iLink 长轮询 ──▶ 消息规范化 ──▶ 调用你的 AI 后端 ──▶ 回复格式化 ──▶ sendmessage
```
Bridge **不负责**:
- 对话历史/记忆管理(那是 AI 后端的事,除非后端无状态)
- 业务逻辑、工具调用编排
- 用户体系、计费
保持薄的好处:协议变了只改 Bridge;AI 后端可以独立演进、独立测试。
## 2. 推荐模块划分
```
bridge/
├── ilink/
│ ├── client.* # HTTP 封装:请求头、鉴权、X-WECHAT-UIN、超时
│ ├── auth.* # 扫码登录流程(get_bot_qrcode / get_qrcode_status)
│ ├── poller.* # 长轮询主循环、游标管理、退避重试
│ ├── sender.* # sendmessage / sendtyping / 分片发送
│ └── media.* # getuploadurl、CDN 上传下载、AES-128-ECB
├── store/
│ ├── account.* # bot_token / ilink_bot_id / baseurl 持久化
│ ├── cursor.* # get_updates_buf 持久化(每次更新立即落盘)
│ └── context.* # peer_id → context_token 映射持久化
├── normalize.* # WeixinMessage → 内部统一消息模型
├── backend.* # 你的 AI 项目的调用封装(HTTP / SDK / 进程)
└── main.* # 装配 + 启动 + 优雅退出
```
参考:官方包 `@tencent-weixin/openclaw-weixin` 的目录结构(`auth/ api/ cdn/ messaging/ monitor/ config/ storage/`)几乎就是这个划分;Java SDK 的门面模式(`OpenClawWeixinSdk` 统一封装 `QrLoginFlowService / WeixinApiClient / WeixinLongPollMonitor / MediaUploadService`)也值得对照。
## 3. 必须持久化的三类状态
| 状态 | 内容 | 更新时机 | 丢失后果 |
|---|---|---|---|
| 账号凭据 | `bot_token`、`ilink_bot_id`、`ilink_user_id`、`baseurl` | 扫码成功时 | 需重新扫码 |
| 同步游标 | `get_updates_buf` | **每次 getupdates 响应后立即** | 重复收到历史消息 |
| 上下文令牌 | `peer_id → context_token` | 每条入站消息 | 无法回复/主动消息送达不到 |
实现建议:
- 单机用 JSON 文件或 SQLite 就够(官方包就是文件存储,Java SDK 用 `FileAccountStore / FileContextTokenStore / FileSyncCursorStore`)
- 游标落盘要**同步写**(或至少 flush),崩溃丢游标 = 重启后消息轰炸
- 文件加个简易锁,防止多开实例写坏状态
## 4. 与 AI 后端的对接模式
按你的 AI 项目形态选一种:
### 模式 A:HTTP API 后端(最常见)
```
poller.onMessage(msg):
reply = http_post("http://127.0.0.1:8000/chat", {
"session_id": msg.peer_id, # 用微信用户 ID 当会话 ID
"text": msg.text,
"attachments": msg.media_paths
})
sender.send_text(msg.peer_id, reply.text, msg.context_token)
```
- 会话 ID 直接映射微信 `from_user_id`,天然一对一私聊
- 后端无状态的话,Bridge 也不存历史;有状态则后端自己管
### 模式 B:本地 SDK / 库调用
Bridge 与 AI 项目同进程(或同语言直接 import),把消息对象传给 Agent 函数,拿回字符串。参考 [cc-weixin](https://github.com/hao-ji-xing/cc-weixin) 把 Claude Agent SDK 的 `query()` 包一层。
### 模式 C:异步任务队列
AI 处理慢(深度研究、图像生成)时:
```
1. 收到消息 → 立即回复「收到,处理中」(可选:sendtyping)
2. 任务入队,worker 处理
3. 完成后用保存的最新 context_token 主动 sendmessage
```
⚠️ 注意 24h 窗口限制(见 06 文档):任务完成后主动推送必须在窗口内才送得到。慢任务一定要设计超时兜底文案。
### 模式 D:多通道网关(进阶)
如果以后还要接 Telegram/飞书/钉钉,把「规范化消息模型 + 统一网关」抽出来,iLink 只是其中一个 adapter。直接参考 [OpenAkita 的架构](https://github.com/openakita/openakita/blob/main/docs/im-channels.md):`Adapter → UnifiedMessage → Gateway → Agent`,发送路径反向。第一版不用做,但内部消息模型(见 03 文档第 7 节)建议一开始就用。
## 5. 可靠性设计
### 5.1 长轮询主循环
伪代码:
```
loop:
try:
resp = getupdates(cursor, timeout=server_timeout + 10s)
if resp.ret == 0:
if resp.get_updates_buf: cursor.save(resp.get_updates_buf)
for msg in resp.msgs: dispatch(msg)
backoff.reset()
elif resp.errcode == -14:
pause_account(1h); notify("需重新扫码")
else:
backoff.retry() # 2s 快速重试,连续 3 次后 30s
except HttpTimeout:
continue # 长轮询正常超时
except NetworkError:
backoff.retry()
```
### 5.2 消息去重
三层兜底:
1. 游标正确持久化(治本)
2. 应用层按消息 ID 去重(`client_id` / 服务端消息 ID,存最近 N 条的 set)
3. 发送侧带唯一 `client_id`(防自己重发)
### 5.3 回复超时
- AI 后端设硬超时(如 60s),超时回「处理超时,请重试」
- 有资料称 24h 窗口外消息送达不到——对「用户发问 → bot 回复」场景这不是问题(立即回复),只影响主动推送
### 5.4 优雅退出
- 捕获 SIGTERM/SIGINT:停 poller → 等正在处理的消息发完 → flush 状态 → 退出
- 不要在 dispatch 中途杀进程,游标已推进但回复没发 = 用户消息被吞
### 5.5 可观测性
- 结构化日志:收消息(peer、类型、长度)、发消息、游标推进、错误码、退避事件
- 关键指标:轮询错误率、-14 出现次数、AI 后端耗时分布
- token、context_token 不要打进日志(打 hash 前缀即可)
## 6. 安全注意
- `bot_token` 等于这个微信 bot 的全部权限:**不入 git、不进日志、文件权限 600**
- 谁扫码谁就是这个 bot 的「主人」,但任何拿到 token 的人都能收发消息——服务器上注意文件权限
- 入站消息内容视为不可信输入:如果 AI 后端有工具调用能力(执行命令、读写文件),提示词注入风险要自己评估
- 建议加白名单:只响应指定 `from_user_id`(比如就你自己),防止别人给 bot 发消息白嫖你的模型 API 额度
## 7. 容量与限制设计
- 微信侧不支持 Markdown:发送前转纯文本
- 长回复分片:按段落或固定字数(如 1500 字)切多条,注意保持顺序
- 媒体大小:入站大图/大文件设上限,超限回复提示而不是硬下载
- AI 后端并发:一个微信号只有一个 bot,但用户可能连发多条——dispatch 建议串行 per-peer(或按 peer 加锁),避免回复乱序
## 8. 测试策略
- 协议层:把 iLink client 抽象成接口,测试时用 mock server 回放抓包 JSON
- 规范化层:构造各类 WeixinMessage JSON(文本/图片/语音/图文混排/多 item)做单元测试
- 端到端:真机扫码,用另一个微信号发消息验证
- 回归重点:游标持久化(重启不重复收)、context_token 传递、-14 恢复流程
## 参考资料
- [openclaw-weixin-sdk 架构图与失败恢复策略](https://github.com/openclaw-java/openclaw-weixin-sdk)
- [OpenAkita 多通道网关架构](https://github.com/openakita/openakita/blob/main/docs/im-channels.md)
- [cc-weixin — 接入 Claude Code Agent 的完整范例](https://github.com/hao-ji-xing/cc-weixin)
+358
View File
@@ -0,0 +1,358 @@
# 05 — 实现指南与代码骨架
> 给出两个可直接改造的最小实现骨架:Node.js(零依赖,官方 demo 的整理版)和 Python(requests 版)。
> 均已按本文档的可靠性建议补强(游标持久化、退避、-14 处理)。媒体收发先略,接入文本跑通后再按 03 文档加。
## 1. 最小流程回顾
```
1. 扫码拿 bot_token(一次性) ← auth
2. loop: ← poller
resp = POST getupdates(cursor)
保存新 cursor
for msg in resp.msgs:
text = msg.item_list[*].text_item.text
reply = 你的AI后端(text) ← backend
POST sendmessage(reply, msg.context_token)
```
## 2. Node.js 骨架(bridge.mjs)
零第三方依赖(Node 18+ 自带 fetch)。二维码展示可加装 `qrcode-terminal`。
```js
// bridge.mjs — 微信 ClawBot iLink Bridge 最小骨架
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { randomBytes } from "node:crypto";
const BASE_URL = "https://ilinkai.weixin.qq.com";
const STATE_FILE = "./bridge-state.json";
// ---------- 状态持久化 ----------
const state = existsSync(STATE_FILE)
? JSON.parse(readFileSync(STATE_FILE, "utf8"))
: { bot_token: null, cursor: "", contexts: {} };
const saveState = () =>
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
// ---------- iLink HTTP 封装 ----------
function headers() {
const uin = Buffer.from(String(randomBytes(4).readUInt32BE(0))).toString("base64");
return {
"Content-Type": "application/json",
AuthorizationType: "ilink_bot_token",
"X-WECHAT-UIN": uin,
...(state.bot_token ? { Authorization: `Bearer ${state.bot_token}` } : {}),
};
}
async function apiPost(path, body, timeoutMs = 45_000) {
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: headers(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
return res.json();
}
// ---------- 1. 扫码登录(一次性) ----------
async function login() {
const qr = await fetch(`${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`)
.then((r) => r.json());
console.log("请用微信扫码:", qr.qrcode_img_content);
// 终端显示二维码: npm i qrcode-terminal,然后 qrcode.generate(qr.qrcode_img_content, {small:true})
const deadline = Date.now() + 8 * 60_000;
while (Date.now() < deadline) {
const s = await fetch(
`${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr.qrcode)}`,
{ headers: { "iLink-App-ClientVersion": "1" } }
).then((r) => r.json());
if (s.status === "confirmed") {
state.bot_token = s.bot_token;
state.bot_id = s.ilink_bot_id;
state.owner_id = s.ilink_user_id;
saveState();
console.log("登录成功, bot:", s.ilink_bot_id);
return;
}
process.stdout.write(`\r等待扫码... status=${s.status} `);
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("扫码超时,请重试");
}
// ---------- 2. AI 后端调用(换成你的项目) ----------
async function askYourAI(peerId, text) {
// 例:调本地 HTTP 后端
// const r = await fetch("http://127.0.0.1:8000/chat", {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ session_id: peerId, text }),
// }).then(r => r.json());
// return r.reply;
return `你说的是:${text}`; // 回声占位
}
// ---------- 3. 发送回复 ----------
async function sendText(toUserId, text, contextToken) {
// 长文本简单分片
for (let i = 0; i < text.length; i += 1500) {
await apiPost("/ilink/bot/sendmessage", {
msg: {
to_user_id: toUserId,
client_id: crypto.randomUUID(),
message_type: 2, // bot 消息
message_state: 2, // FINISH
context_token: contextToken,
item_list: [{ type: 1, text_item: { text: text.slice(i, i + 1500) } }],
},
});
}
}
// ---------- 4. 长轮询主循环 ----------
async function pollLoop() {
let failures = 0;
for (;;) {
try {
const resp = await apiPost("/ilink/bot/getupdates", {
get_updates_buf: state.cursor,
base_info: { channel_version: "1.0.2" },
});
if (resp.errcode === -14) {
console.error("会话过期(errcode=-14),请删除 state 文件重新扫码");
process.exit(2);
}
if (resp.ret !== 0 && resp.errcode !== undefined) {
throw new Error(`getupdates 错误: ${JSON.stringify(resp)}`);
}
if (resp.get_updates_buf) {
state.cursor = resp.get_updates_buf;
saveState(); // 游标立即落盘
}
for (const msg of resp.msgs ?? []) {
if (msg.message_type !== 1) continue; // 只处理用户消息
const text = (msg.item_list ?? [])
.filter((it) => it.type === 1)
.map((it) => it.text_item?.text ?? "")
.join("\n")
.trim();
if (!text) continue; // 媒体消息此处略,按 03 文档扩展
state.contexts[msg.from_user_id] = msg.context_token;
saveState();
try {
const reply = await askYourAI(msg.from_user_id, text);
await sendText(msg.from_user_id, reply, msg.context_token);
} catch (e) {
console.error("处理消息失败:", e);
await sendText(msg.from_user_id, "处理时出错了,请重试", msg.context_token)
.catch(() => {});
}
}
failures = 0;
} catch (e) {
// AbortError(长轮询超时) 视为正常,直接下一轮
if (e.name === "TimeoutError" || e.name === "AbortError") continue;
failures++;
const wait = failures <= 3 ? 2000 : 30_000;
console.error(`轮询异常(${failures}次),${wait / 1000}s 后重试:`, e.message);
await new Promise((r) => setTimeout(r, wait));
}
}
}
// ---------- main ----------
if (!state.bot_token) await login();
await pollLoop();
```
运行:
```bash
node bridge.mjs # 首次会打印二维码链接,微信扫码确认
```
## 3. Python 骨架(bridge.py)
依赖只有 `requests`。二维码展示可加装 `qrcode` + 终端渲染。
```python
# bridge.py — 微信 ClawBot iLink Bridge 最小骨架
import json, os, time, uuid, base64, random, requests
BASE_URL = "https://ilinkai.weixin.qq.com"
STATE_FILE = "./bridge-state.json"
# ---------- 状态持久化 ----------
state = {"bot_token": None, "cursor": "", "contexts": {}}
if os.path.exists(STATE_FILE):
state.update(json.load(open(STATE_FILE)))
def save_state():
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
os.chmod(STATE_FILE, 0o600)
# ---------- iLink HTTP 封装 ----------
def headers():
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode()).decode(),
}
if state["bot_token"]:
h["Authorization"] = f"Bearer {state['bot_token']}"
return h
def api_post(path, body, timeout=45):
r = requests.post(BASE_URL + path, headers=headers(), json=body, timeout=timeout)
return r.json()
# ---------- 1. 扫码登录(一次性) ----------
def login():
qr = requests.get(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3").json()
print("请用微信扫码:", qr["qrcode_img_content"])
deadline = time.time() + 8 * 60
while time.time() < deadline:
s = requests.get(
f"{BASE_URL}/ilink/bot/get_qrcode_status",
params={"qrcode": qr["qrcode"]},
headers={"iLink-App-ClientVersion": "1"},
).json()
if s.get("status") == "confirmed":
state.update(bot_token=s["bot_token"], bot_id=s["ilink_bot_id"],
owner_id=s["ilink_user_id"])
save_state()
print("登录成功, bot:", s["ilink_bot_id"])
return
time.sleep(1)
raise RuntimeError("扫码超时")
# ---------- 2. AI 后端调用(换成你的项目) ----------
def ask_your_ai(peer_id: str, text: str) -> str:
# r = requests.post("http://127.0.0.1:8000/chat",
# json={"session_id": peer_id, "text": text}).json()
# return r["reply"]
return f"你说的是:{text}"
# ---------- 3. 发送回复 ----------
def send_text(to_user_id, text, context_token):
for i in range(0, len(text), 1500):
api_post("/ilink/bot/sendmessage", {
"msg": {
"to_user_id": to_user_id,
"client_id": str(uuid.uuid4()),
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{"type": 1, "text_item": {"text": text[i:i+1500]}}],
}
})
# ---------- 4. 长轮询主循环 ----------
def poll_loop():
failures = 0
while True:
try:
resp = api_post("/ilink/bot/getupdates", {
"get_updates_buf": state["cursor"],
"base_info": {"channel_version": "1.0.2"},
})
if resp.get("errcode") == -14:
print("会话过期(errcode=-14),请删除 state 文件重新扫码")
raise SystemExit(2)
if resp.get("ret", 0) != 0:
raise RuntimeError(f"getupdates 错误: {resp}")
if resp.get("get_updates_buf"):
state["cursor"] = resp["get_updates_buf"]
save_state()
for msg in resp.get("msgs") or []:
if msg.get("message_type") != 1:
continue
text = "\n".join(
it.get("text_item", {}).get("text", "")
for it in msg.get("item_list", []) if it.get("type") == 1
).strip()
if not text:
continue
state["contexts"][msg["from_user_id"]] = msg["context_token"]
save_state()
try:
reply = ask_your_ai(msg["from_user_id"], text)
send_text(msg["from_user_id"], reply, msg["context_token"])
except Exception as e:
print("处理消息失败:", e)
try:
send_text(msg["from_user_id"], "处理时出错了,请重试",
msg["context_token"])
except Exception:
pass
failures = 0
except requests.exceptions.Timeout:
continue # 长轮询正常超时
except Exception as e:
failures += 1
wait = 2 if failures <= 3 else 30
print(f"轮询异常({failures}次),{wait}s 后重试:", e)
time.sleep(wait)
if __name__ == "__main__":
if not state["bot_token"]:
login()
poll_loop()
```
## 4. 接入你自己 AI 项目的改造点
只需替换 `askYourAI` / `ask_your_ai` 一个函数:
| 你的后端形态 | 改法 |
|---|---|
| HTTP API(FastAPI/Flask/Express…) | 函数里 POST 过去,`session_id` 传 `peer_id` |
| OpenAI 兼容接口 | 函数里组 messages 调 chat/completions,历史自己存(JSON/SQLite,按 peer_id 分键) |
| LangChain / LangGraph | 函数里调 chain.invoke,memory 用 peer_id 做会话键 |
| 本地 Agent 框架(Claude Agent SDK 等) | 参考 [cc-weixin](https://github.com/hao-ji-xing/cc-weixin) 的 `askClaude()` 包装 |
| 命令行工具 | `subprocess` 调一下取 stdout(注意超时与转义) |
## 5. 常见坑(按踩坑频率排序)
1. **游标没存/存晚了** → 重启后消息重复轰炸。收到响应立刻落盘。
2. **回复忘带 `context_token`** → 消息发不出或关联不上对话。
3. **HTTP 客户端超时 < 长轮询超时** → 每轮都假性超时。骨架里给了 45s。
4. **Markdown 直接发** → 微信不渲染,先转纯文本。
5. **只处理 `item_list[0]`** → 图文混排丢内容。
6. **token 提交进 git** → 泄漏=bot 被接管。state 文件加 `.gitignore`。
7. **消息处理抛异常没兜住** → 整个轮询循环挂掉。骨架里已包 try。
8. **多开实例** → 同 token 多处轮询互相抢游标,状态错乱。单实例跑。
## 6. 下一步扩展路线
1. 文本收发跑通(本骨架)
2. 接入你的 AI 后端 + 会话历史
3. 入站图片/语音(CDN 下载 + AES 解密,见 03 文档)
4. `sendtyping`(处理慢时提升体验)
5. 出站图片/文件(getuploadurl + CDN 上传)
6. 白名单、限流、多通道(按 04 文档模式 D 演进)
## 7. 参考项目清单
| 项目 | 值得借鉴的部分 |
|---|---|
| [hao-ji-xing/openclaw-weixin](https://github.com/hao-ji-xing/openclaw-weixin) | `demo.mjs` 裸调最小实现、协议逆向文档 |
| [hao-ji-xing/cc-weixin](https://github.com/hao-ji-xing/cc-weixin) | Agent SDK 的接入包装方式 |
| [openclaw-java/openclaw-weixin-sdk](https://github.com/openclaw-java/openclaw-weixin-sdk) | 完整工程化:状态存储、退避策略、-14 暂停窗口、typing、媒体服务 |
| [openakita/openakita](https://github.com/openakita/openakita) | Python 适配器、多通道网关抽象、媒体惰性下载策略 |
| `@tencent-weixin/openclaw-weixin`(npm) | 官方协议实现,字段有疑问时的最终依据:`npm pack @tencent-weixin/openclaw-weixin` |
+87
View File
@@ -0,0 +1,87 @@
# 06 — 平台限制、合规条款与 FAQ
## 1. 硬性限制汇总
| 限制 | 说明 | 影响 |
|---|---|---|
| 一个微信号 = 一个 bot 实例 | 扫码绑定关系一对一 | 不能多开 bot;多开轮询会互相抢游标 |
| 仅私聊 | 群聊未开放(协议有 `group_id` 字段,可能未来开放) | 做不了群助手,目前 |
| 无历史消息 API | 只有 `get_updates_buf` 游标 | 掉线期间的消息语义未明确,建议按「可能丢失」设计 |
| 回复时效窗口 ⚠️ | 有资料称用户最后一条消息后 24h 内 bot 回复才送得到 | 主动推送/慢任务受约束;务必实测 |
| 主动消息依赖 context_token | token 随入站消息下发 | 冷启动无法主动找人,得用户先开口 |
| 客户端版本 | iOS ≥ 8.0.70(安卓/鸿蒙版本要求各资料不一) | 用户侧看不到插件=没被放量或版本低 |
| 灰度放量 | 功能逐步开放 | 部分账号没有插件入口,无解,等 |
| 速率限制未公开 | 官方未给数字 | 别做高频轰炸,正常对话节奏不会触发 |
## 2. 官方条款要点(《微信 ClawBot 功能使用条款》)
> 以下为开发者视角的转述,不替代原文。做正式产品前请读条款原文。
- **腾讯只是管道(3.2)**:仅提供信息收发,不存储输入/输出内容,不提供 AI 服务。AI 输出由你负责。
- **腾讯保留控制权(4.7)**:可决定支持接入的 AI 服务类型、范围、收发规模与频率;可对内容与连接进行识别,采取提示、拦截、阻断。
- **数据(5.3)**:消息内容不存储;但 IP、操作记录、设备信息会被收集用于安全审计。
- **禁止行为(4.6)**:绕过/破解微信技术保护措施;违法违规;危害网络与数据安全;侵犯他人权益。
- **服务可终止(7.2)**:腾讯可根据业务发展变更、中断、终止本功能。
**工程含义**:
1. 核心业务不要单点依赖 iLink,留降级通道(如邮件、Telegram bot)
2. 内容合规责任在你(AI 输出的过滤/审核是你的事)
3. 别拿它做营销群发——高频主动消息既是条款雷区也受 24h 窗口限制
## 3. 资料间的矛盾与未证实事项
整理时发现以下说法在不同来源间不一致,使用前先实测:
| 事项 | 说法 A | 说法 B | 建议 |
|---|---|---|---|
| 客户端支持 | 仅 iOS 8.0.70+ | 安卓 ≥8.0.70、鸿蒙 ≥8.0.16.40 也支持 | 以目标用户实测为准 |
| 消息类型 | 仅文本 | 图片/语音/文件/视频均可 | 文本先行,媒体逐个实测 |
| 出站语音 | bot 不能发语音 | 适配器已实现语音上传 | 实测 |
| 群聊 | 未开放 | 协议有 `group_id` 字段 | 按不可用设计 |
| token 有效期 | 长期有效 | 未公布,会 -14 过期 | 实现重扫码路径即可 |
| 24h 回复窗口 | 存在 | 部分资料未提 | 主动推送功能先实测 |
## 4. FAQ
**Q:必须要装 OpenClaw 吗?**
A:不需要。iLink 是标准 HTTP API,官方 npm 包只是封装。直接 `fetch`/`requests` 调即可,见 05 文档骨架。
**Q:需要公网 IP / 域名 / 备案吗?**
A:都不需要。收消息是长轮询(你的程序主动连腾讯服务器),不是 webhook。
**Q:bot_token 会过期吗?过期了怎么办?**
A:官方未公布有效期。`getupdates` 返回 `errcode=-14` 即会话过期,此时应暂停轮询、删除本地凭据、重新走扫码流程。实现时把这条路径做好即可无忧。
**Q:能同时跑多个实例分摊负载吗?**
A:不能。一个微信号绑一个 bot;多个实例同时轮询会互相覆盖游标导致丢消息/重复消息。单实例部署,实例内做并发处理。
**Q:用户发的语音能直接拿到文字吗?**
A:能。语音消息的 `voice_item.text` 附带微信侧的 ASR 转写,大多数场景直接用转写文本即可,不用自己解码 silk。
**Q:AI 处理要几分钟(如深度研究),怎么回?**
A:先立即回一条「收到,处理中」(或 sendtyping),任务完成后用最新 context_token 主动发结果。注意 24h 窗口(见上),超窗消息可能送不到。
**Q:能给别人用吗(多用户 bot)?**
A:技术上任何用户给 bot 发消息你都能收到(`from_user_id` 区分),按 peer 建会话即可。但注意:你的模型 API 成本、内容合规责任、以及腾讯对「信息收发规模」的控制权。建议加白名单。
**Q:和公众号/企业微信比,选哪个?**
A:给自己或小圈子用、要微信原生体验 → ClawBot;要认证主体、群发、关注者体系 → 公众号;企业场景 → 企业微信机器人(见 01 文档对比表)。
**Q:封号风险?**
A:这是官方插件通道,正常使用不按灰产协议处理,封号风险远低于 itchat/Wechaty 方案。但违反条款(营销轰炸、违规内容、超高频)腾讯有权拦截/终止。
**Q:协议字段跟文档对不上怎么办?**
A:最终依据是官方包源码:`npm pack @tencent-weixin/openclaw-weixin` 下载后看 `src/`。协议在 1.0.2 → 2.1.x 间主体未变,但细节以你抓到的版本为准。
## 5. 官方与社区资源
| 资源 | 链接 |
|---|---|
| 官方 CLI 包 | `npm view @tencent-weixin/openclaw-weixin-cli` |
| 官方协议包 | `npm view @tencent-weixin/openclaw-weixin` |
| OpenClaw 文档 | https://docs.openclaw.ai |
| 协议逆向分析 | https://github.com/hao-ji-xing/openclaw-weixin |
| Java SDK | https://github.com/openclaw-java/openclaw-weixin-sdk |
| Python 多通道参考 | https://github.com/openakita/openakita |
| Claude Code 接入示例 | https://github.com/hao-ji-xing/cc-weixin |
+31
View File
@@ -0,0 +1,31 @@
# examples — 多语言 iLink 调用示例
每个语言一份**单文件、可独立运行**的最小示例,覆盖同一套流程:
```
扫码登录(get_bot_qrcode / get_qrcode_status) → 长轮询(getupdates) → 回声回复(sendmessage)
```
AI 后端接入点统一是 `askYourAI` / `ask_your_ai` / `AskYourAI` 这一个函数,默认是回声(echo),换成你自己的项目调用即可。
| 语言 | 文件 | 依赖 | 运行 |
|---|---|---|---|
| Python | [python/ilink_bridge.py](python/ilink_bridge.py) | `requests` | `python3 ilink_bridge.py` |
| Go | [go/main.go](go/main.go) | 仅标准库 | `go run main.go` |
| Java | [java/IlinkBridge.java](java/IlinkBridge.java) | JDK 11+ | `javac IlinkBridge.java && java IlinkBridge` |
| C | [c/ilink_client.c](c/ilink_client.c) | libcurl | `gcc ilink_client.c -o ilink -lcurl && ./ilink` |
| C++ | [cpp/ilink_client.cpp](cpp/ilink_client.cpp) | libcurl | `g++ ilink_client.cpp -o ilink -lcurl && ./ilink` |
## 共同约定
- 首次运行会打印二维码链接,用微信扫码确认后拿到 `bot_token`,写入本目录的 `bridge-state.json`(权限 600,**不要提交 git**)
- 之后重启免扫码,直接进入长轮询
- 收到文本消息原样回声;游标 `get_updates_buf` 每轮落盘
- `errcode=-14`(会话过期)时退出并提示重新扫码(删掉 state 文件再运行)
## 有意省略的部分(保持示例简单)
- **JSON 库**:Go/Python 用标准方案;Java/C/C++ 为了让示例零额外依赖,用了最朴素的字段提取,注释里标了位置——正式项目请换 Jackson / cJSON / nlohmann-json
- **媒体消息**(图片/语音/文件的 AES-128-ECB + CDN):见 [../docs/03-messages-and-media.md](../docs/03-messages-and-media.md),字段细节以官方包源码 `src/cdn/` 为准
- **sendtyping / getconfig**:非必需,协议见 [../docs/02-ilink-protocol.md](../docs/02-ilink-protocol.md) 第 9 节
- 多 item(图文混排)只做了文本拼接,完整规范化模型见 03 文档第 7 节
+203
View File
@@ -0,0 +1,203 @@
/* ilink_client.c — 微信 ClawBot iLink 最小示例(C,依赖 libcurl)
* 流程: 扫码登录 → 长轮询收消息 → 回声回复
* 编译: gcc ilink_client.c -o ilink -lcurl
*
* 注意: 为控制依赖,JSON 用最朴素的字符串查找提取,仅适合演示。
* 正式项目请引入 cJSON,媒体加解密见 docs/03(可用 OpenSSL AES-128-ECB)。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <sys/stat.h>
#include <curl/curl.h>
#define BASE_URL "https://ilinkai.weixin.qq.com"
#define STATE_FILE "bridge-state.json"
/* ---------- 响应缓冲 ---------- */
struct buf { char *data; size_t len; };
static size_t on_write(char *ptr, size_t size, size_t nmemb, void *ud) {
struct buf *b = ud;
size_t n = size * nmemb;
b->data = realloc(b->data, b->len + n + 1);
memcpy(b->data + b->len, ptr, n);
b->len += n;
b->data[b->len] = 0;
return n;
}
/* ---------- 状态: bot_token / 游标(极简 key=value 行存储) ---------- */
static char g_token[512] = "", g_cursor[1024] = "";
static void load_state(void) {
FILE *f = fopen(STATE_FILE, "r");
if (!f) return;
char line[2048], key[64], val[1900];
while (fgets(line, sizeof line, f))
if (sscanf(line, "%63[^=]=%1899[^\n]", key, val) == 2) {
if (!strcmp(key, "bot_token")) snprintf(g_token, sizeof g_token, "%s", val);
if (!strcmp(key, "cursor")) snprintf(g_cursor, sizeof g_cursor, "%s", val);
}
fclose(f);
}
static void save_state(void) {
FILE *f = fopen(STATE_FILE, "w");
fprintf(f, "bot_token=%s\ncursor=%s\n", g_token, g_cursor);
fclose(f);
chmod(STATE_FILE, 0600);
}
/* ---------- 通用请求: 鉴权头 + 防重放 X-WECHAT-UIN ---------- */
static char *http(const char *method, const char *path, const char *json_body, long timeout) {
CURL *c = curl_easy_init();
struct buf b = {0};
char url[512], uin[32], auth[600];
snprintf(url, sizeof url, "%s%s", BASE_URL, path);
/* X-WECHAT-UIN: base64(十进制字符串(随机uint32)),简化用伪随机 */
snprintf(uin, sizeof uin, "%u", (unsigned)(rand() ^ (unsigned)time(NULL)));
char uin_b64[64];
/* 极简 base64(仅数字串,查表法) */
const char *T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t ul = strlen(uin), o = 0;
for (size_t i = 0; i < ul; i += 3) {
unsigned v = uin[i] << 16 | (i+1<ul ? uin[i+1] : 0) << 8 | (i+2<ul ? uin[i+2] : 0);
uin_b64[o++] = T[v>>18&63]; uin_b64[o++] = T[v>>12&63];
uin_b64[o++] = i+1<ul ? T[v>>6&63] : '=';
uin_b64[o++] = i+2<ul ? T[v&63] : '=';
}
uin_b64[o] = 0;
struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token");
char uin_hdr[96]; snprintf(uin_hdr, sizeof uin_hdr, "X-WECHAT-UIN: %s", uin_b64);
hdrs = curl_slist_append(hdrs, uin_hdr);
if (g_token[0]) {
snprintf(auth, sizeof auth, "Authorization: Bearer %s", g_token);
hdrs = curl_slist_append(hdrs, auth);
}
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_write);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &b);
curl_easy_setopt(c, CURLOPT_TIMEOUT, timeout);
if (!strcmp(method, "POST")) curl_easy_setopt(c, CURLOPT_POSTFIELDS, json_body);
CURLcode rc = curl_easy_perform(c);
curl_slist_free_all(hdrs);
curl_easy_cleanup(c);
if (rc != CURLE_OK) { free(b.data); return NULL; } /* 超时/网络错误返回 NULL */
return b.data ? b.data : strdup("");
}
/* ---------- 极简 JSON 字段提取(仅演示!正式项目用 cJSON) ---------- */
static int pick(const char *json, const char *key, char *out, size_t outsz) {
char pat[128]; snprintf(pat, sizeof pat, "\"%s\":\"", key);
const char *p = strstr(json, pat);
if (!p) return 0;
p += strlen(pat);
const char *e = strchr(p, '"');
if (!e || (size_t)(e - p) >= outsz) return 0;
memcpy(out, p, e - p); out[e - p] = 0;
return 1;
}
/* ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ---------- */
static void login(void) {
char *qr = http("GET", "/ilink/bot/get_bot_qrcode?bot_type=3", NULL, 15);
char qrcode[256], qrurl[512];
if (!qr || !pick(qr, "qrcode", qrcode, sizeof qrcode)) { fprintf(stderr, "获取二维码失败\n"); exit(1); }
pick(qr, "qrcode_img_content", qrurl, sizeof qrurl);
printf("请用微信扫码: %s\n", qrurl);
free(qr);
char path[320], status[32], token[512];
snprintf(path, sizeof path, "/ilink/bot/get_qrcode_status?qrcode=%s", qrcode);
for (int i = 0; i < 480; i++) { /* 最多等 8 分钟 */
char *s = http("GET", path, NULL, 15);
if (s && pick(s, "status", status, sizeof status) && !strcmp(status, "confirmed")) {
pick(s, "bot_token", token, sizeof token);
snprintf(g_token, sizeof g_token, "%s", token);
save_state();
printf("登录成功\n");
free(s);
return;
}
free(s);
sleep(1);
}
fprintf(stderr, "扫码超时\n"); exit(1);
}
/* ---------- 你的 AI 后端: 替换成自己项目的调用 ---------- */
static void ask_your_ai(const char *peer, const char *text, char *out, size_t n) {
snprintf(out, n, "你说的是:%s", text);
}
/* ---------- 接口4: POST /ilink/bot/sendmessage ---------- */
static void send_text(const char *to, const char *text, const char *ctx_token) {
/* 演示不处理 JSON 转义与分片,正式项目务必转义 \ " 换行 并按需分片 */
char body[4096];
snprintf(body, sizeof body,
"{\"msg\":{\"to_user_id\":\"%s\",\"client_id\":\"c-%ld\","
"\"message_type\":2,\"message_state\":2,\"context_token\":\"%s\","
"\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"%s\"}}]}}",
to, (long)time(NULL), ctx_token, text);
free(http("POST", "/ilink/bot/sendmessage", body, 15));
}
/* ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ---------- */
static void poll_loop(void) {
int failures = 0;
char body[1200];
for (;;) {
snprintf(body, sizeof body,
"{\"get_updates_buf\":\"%s\",\"base_info\":{\"channel_version\":\"1.0.2\"}}",
g_cursor);
char *resp = http("POST", "/ilink/bot/getupdates", body, 45);
if (!resp) { /* 超时/网络错误 */
failures++;
int wait = failures <= 3 ? 2 : 30;
fprintf(stderr, "轮询异常(%d次),%ds 后重试\n", failures, wait);
sleep(wait);
continue;
}
if (strstr(resp, "\"errcode\":-14")) {
fprintf(stderr, "会话过期(errcode=-14),请删除 %s 重新扫码\n", STATE_FILE);
free(resp); exit(2);
}
char buf[1024];
if (pick(resp, "get_updates_buf", buf, sizeof buf) && buf[0]) {
snprintf(g_cursor, sizeof g_cursor, "%s", buf);
save_state(); /* 游标立即落盘 */
}
/* 演示级提取: 仅取第一条消息的文本/context_token/from_user_id */
char peer[128], token[512], text[1500];
if (pick(resp, "from_user_id", peer, sizeof peer) &&
pick(resp, "context_token", token, sizeof token) &&
pick(resp, "text", text, sizeof text) &&
!strstr(resp, "\"message_type\":2")) {
char reply[1700];
ask_your_ai(peer, text, reply, sizeof reply);
send_text(peer, reply, token);
}
free(resp);
failures = 0;
}
}
int main(void) {
srand((unsigned)time(NULL));
curl_global_init(CURL_GLOBAL_DEFAULT);
load_state();
if (!g_token[0]) login();
poll_loop();
curl_global_cleanup();
return 0;
}
+194
View File
@@ -0,0 +1,194 @@
// ilink_client.cpp — 微信 ClawBot iLink 最小示例(C++17,依赖 libcurl)
// 流程: 扫码登录 → 长轮询收消息 → 回声回复
// 编译: g++ -std=c++17 ilink_client.cpp -o ilink -lcurl
//
// 注意: 为控制依赖,JSON 用最朴素的字符串查找提取,仅适合演示。
// 正式项目请引入 nlohmann/json(单头文件),媒体加解密见 docs/03。
#include <curl/curl.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <optional>
#include <random>
#include <string>
#include <thread>
#include <sys/stat.h>
static const std::string BASE_URL = "https://ilinkai.weixin.qq.com";
static const std::string STATE_FILE = "bridge-state.json";
// ---------- 状态: bot_token / 游标(极简 key=value 行存储) ----------
static std::string g_token, g_cursor;
static void load_state() {
std::ifstream f(STATE_FILE);
std::string line;
while (std::getline(f, line)) {
auto eq = line.find('=');
if (eq == std::string::npos) continue;
auto k = line.substr(0, eq), v = line.substr(eq + 1);
if (k == "bot_token") g_token = v;
if (k == "cursor") g_cursor = v;
}
}
static void save_state() {
std::ofstream f(STATE_FILE, std::ios::trunc);
f << "bot_token=" << g_token << "\ncursor=" << g_cursor << "\n";
f.close();
chmod(STATE_FILE.c_str(), 0600);
}
// ---------- HTTP 封装 ----------
static size_t on_write(char *p, size_t s, size_t n, void *ud) {
auto *out = static_cast<std::string *>(ud);
out->append(p, s * n);
return s * n;
}
static std::string b64(const std::string &in) { // 极简 base64
static const char *T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string o;
for (size_t i = 0; i < in.size(); i += 3) {
unsigned v = (unsigned char)in[i] << 16 |
(i+1 < in.size() ? (unsigned char)in[i+1] : 0) << 8 |
(i+2 < in.size() ? (unsigned char)in[i+2] : 0);
o += T[v>>18&63]; o += T[v>>12&63];
o += i+1 < in.size() ? T[v>>6&63] : '=';
o += i+2 < in.size() ? T[v&63] : '=';
}
return o;
}
// 返回空 optional 表示传输层错误(超时/断网);业务错误看响应内容
static std::optional<std::string> http(const std::string &method, const std::string &path,
const std::string &body = "", long timeout = 45) {
CURL *c = curl_easy_init();
std::string resp;
static thread_local std::mt19937 rng{std::random_device{}()};
struct curl_slist *hdrs = nullptr;
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token");
std::string uin = "X-WECHAT-UIN: " + b64(std::to_string(rng())); // 防重放
hdrs = curl_slist_append(hdrs, uin.c_str());
std::string auth;
if (!g_token.empty()) {
auth = "Authorization: Bearer " + g_token;
hdrs = curl_slist_append(hdrs, auth.c_str());
}
std::string url = BASE_URL + path;
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_write);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp);
curl_easy_setopt(c, CURLOPT_TIMEOUT, timeout);
if (method == "POST") curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
CURLcode rc = curl_easy_perform(c);
curl_slist_free_all(hdrs);
curl_easy_cleanup(c);
if (rc != CURLE_OK) return std::nullopt;
return resp;
}
// ---------- 极简 JSON 字段提取(仅演示!正式项目用 nlohmann/json) ----------
static std::string pick(const std::string &json, const std::string &key) {
auto p = json.find("\"" + key + "\":\"");
if (p == std::string::npos) return "";
p += key.size() + 4;
auto e = json.find('"', p);
return e == std::string::npos ? "" : json.substr(p, e - p);
}
static std::string esc(const std::string &s) { // 最小 JSON 转义
std::string o;
for (char ch : s) {
if (ch == '"' || ch == '\\') { o += '\\'; o += ch; }
else if (ch == '\n') o += "\\n";
else o += ch;
}
return o;
}
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
static void login() {
auto qr = http("GET", "/ilink/bot/get_bot_qrcode?bot_type=3", "", 15);
if (!qr) { std::cerr << "获取二维码失败\n"; exit(1); }
std::string qrcode = pick(*qr, "qrcode");
std::cout << "请用微信扫码: " << pick(*qr, "qrcode_img_content") << "\n";
for (int i = 0; i < 480; i++) { // 最多等 8 分钟
auto s = http("GET", "/ilink/bot/get_qrcode_status?qrcode=" + qrcode, "", 15);
if (s && pick(*s, "status") == "confirmed") { // wait → scaned → confirmed
g_token = pick(*s, "bot_token");
save_state();
std::cout << "登录成功\n";
return;
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cerr << "扫码超时\n"; exit(1);
}
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
static std::string ask_your_ai(const std::string &peer, const std::string &text) {
return "你说的是:" + text;
}
// ---------- 接口4: POST /ilink/bot/sendmessage ----------
static void send_text(const std::string &to, const std::string &text, const std::string &token) {
for (size_t i = 0; i < std::max(text.size(), size_t(1)); i += 1500) { // 长文本分片
std::string body =
"{\"msg\":{\"to_user_id\":\"" + esc(to) + "\",\"client_id\":\"cpp-" +
std::to_string(std::time(nullptr)) + "-" + std::to_string(i) + "\","
"\"message_type\":2,\"message_state\":2,\"context_token\":\"" + esc(token) + "\","
"\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"" +
esc(text.substr(i, 1500)) + "\"}}]}}";
http("POST", "/ilink/bot/sendmessage", body, 15);
}
}
// ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ----------
[[noreturn]] static void poll_loop() {
int failures = 0;
for (;;) {
auto resp = http("POST", "/ilink/bot/getupdates",
"{\"get_updates_buf\":\"" + esc(g_cursor) + "\","
"\"base_info\":{\"channel_version\":\"1.0.2\"}}", 45);
if (!resp) { // 超时/网络错误
failures++;
int wait = failures <= 3 ? 2 : 30; // 退避: 2s ×3 → 30s
std::cerr << "轮询异常(" << failures << "次)," << wait << "s 后重试\n";
std::this_thread::sleep_for(std::chrono::seconds(wait));
continue;
}
if (resp->find("\"errcode\":-14") != std::string::npos) {
std::cerr << "会话过期(errcode=-14),请删除 " << STATE_FILE << " 重新扫码\n";
exit(2);
}
std::string buf = pick(*resp, "get_updates_buf");
if (!buf.empty()) { // 游标立即落盘
g_cursor = buf;
save_state();
}
// 演示级提取: 仅第一条文本消息(正式项目按 msgs[] 完整解析)
std::string peer = pick(*resp, "from_user_id");
std::string token = pick(*resp, "context_token");
std::string text = pick(*resp, "text");
if (!peer.empty() && !token.empty() && !text.empty() &&
resp->find("\"message_type\":2") == std::string::npos) {
send_text(peer, ask_your_ai(peer, text), token);
}
failures = 0;
}
}
int main() {
curl_global_init(CURL_GLOBAL_DEFAULT);
load_state();
if (g_token.empty()) login();
poll_loop();
}
+223
View File
@@ -0,0 +1,223 @@
// main.go — 微信 ClawBot iLink 最小示例(Go,仅标准库)
// 流程: 扫码登录 → 长轮询收消息 → 回声回复
// 运行: go run main.go
package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
const baseURL = "https://ilinkai.weixin.qq.com"
var stateFile = filepath.Join(func() string { d, _ := os.Executable(); return filepath.Dir(d) }(), "bridge-state.json")
// ---------- 状态持久化: bot_token / 游标 / context_token ----------
type State struct {
BotToken string `json:"bot_token"`
BotID string `json:"bot_id,omitempty"`
Cursor string `json:"cursor"`
Contexts map[string]string `json:"contexts"`
}
var state = State{Contexts: map[string]string{}}
func loadState() {
if b, err := os.ReadFile(stateFile); err == nil {
json.Unmarshal(b, &state)
}
if state.Contexts == nil {
state.Contexts = map[string]string{}
}
}
func saveState() {
b, _ := json.MarshalIndent(state, "", " ")
os.WriteFile(stateFile, b, 0o600)
}
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
func headers(req *http.Request) {
var u [4]byte
rand.Read(u[:])
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("X-WECHAT-UIN", base64.StdEncoding.EncodeToString(
[]byte(fmt.Sprint(binary.BigEndian.Uint32(u[:])))))
if state.BotToken != "" {
req.Header.Set("Authorization", "Bearer "+state.BotToken)
}
}
func apiPost(path string, body any, timeout time.Duration) (map[string]any, error) {
buf, _ := json.Marshal(body)
req, err := http.NewRequest("POST", baseURL+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
headers(req)
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out map[string]any
return out, json.NewDecoder(resp.Body).Decode(&out)
}
func apiGet(path string, extraHeaders map[string]string) (map[string]any, error) {
req, err := http.NewRequest("GET", baseURL+path, nil)
if err != nil {
return nil, err
}
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out map[string]any
return out, json.NewDecoder(resp.Body).Decode(&out)
}
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
func login() error {
qr, err := apiGet("/ilink/bot/get_bot_qrcode?bot_type=3", nil)
if err != nil {
return err
}
fmt.Println("请用微信扫码:", qr["qrcode_img_content"])
deadline := time.Now().Add(8 * time.Minute)
for time.Now().Before(deadline) {
s, err := apiGet("/ilink/bot/get_qrcode_status?qrcode="+url(qr["qrcode"]),
map[string]string{"iLink-App-ClientVersion": "1"})
if err == nil && s["status"] == "confirmed" { // wait → scaned → confirmed
state.BotToken, _ = s["bot_token"].(string)
state.BotID, _ = s["ilink_bot_id"].(string)
saveState()
fmt.Println("登录成功, bot:", state.BotID)
return nil
}
time.Sleep(time.Second)
}
return fmt.Errorf("扫码超时")
}
func url(v any) string { // 极简 query 转义(qrcode 内容一般安全,正式代码用 url.QueryEscape)
s, _ := v.(string)
return s
}
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
func AskYourAI(peerID, text string) string {
return "你说的是:" + text
}
// ---------- 接口4: POST /ilink/bot/sendmessage ----------
func sendText(toUserID, text, contextToken string) {
if text == "" {
text = " "
}
for i := 0; i < len(text); i += 1500 { // 长文本分片(按字节切仅适合演示,正式按 rune 切)
end := i + 1500
if end > len(text) {
end = len(text)
}
apiPost("/ilink/bot/sendmessage", map[string]any{
"msg": map[string]any{
"to_user_id": toUserID,
"client_id": fmt.Sprintf("%d-%d", time.Now().UnixNano(), i),
"message_type": 2, // bot 消息
"message_state": 2, // FINISH
"context_token": contextToken,
"item_list": []any{map[string]any{
"type": 1, "text_item": map[string]string{"text": text[i:end]},
}},
},
}, 15*time.Second)
}
}
// ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ----------
func pollLoop() {
failures := 0
for {
resp, err := apiPost("/ilink/bot/getupdates", map[string]any{
"get_updates_buf": state.Cursor,
"base_info": map[string]string{"channel_version": "1.0.2"},
}, 45*time.Second)
if err != nil {
if os.IsTimeout(err) { // 长轮询正常超时
continue
}
failures++
wait := 2 * time.Second
if failures > 3 {
wait = 30 * time.Second // 退避: 2s ×3 → 30s
}
fmt.Printf("轮询异常(%d次),%v 后重试: %v\n", failures, wait, err)
time.Sleep(wait)
continue
}
if code, _ := resp["errcode"].(float64); code == -14 { // 会话过期
fmt.Println("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码")
os.Exit(2)
}
if buf, ok := resp["get_updates_buf"].(string); ok && buf != "" {
state.Cursor = buf // 游标立即落盘
saveState()
}
msgs, _ := resp["msgs"].([]any)
for _, m := range msgs {
msg, _ := m.(map[string]any)
if t, _ := msg["message_type"].(float64); t != 1 { // 只处理用户消息
continue
}
text := ""
for _, it := range msg["item_list"].([]any) {
item, _ := it.(map[string]any)
if t, _ := item["type"].(float64); t == 1 {
if ti, ok := item["text_item"].(map[string]any); ok {
s, _ := ti["text"].(string)
text += s + "\n"
}
}
}
if text == "" {
continue
}
peer, _ := msg["from_user_id"].(string)
token, _ := msg["context_token"].(string)
state.Contexts[peer] = token
saveState()
sendText(peer, AskYourAI(peer, text), token)
}
failures = 0
}
}
func main() {
loadState()
if state.BotToken == "" {
if err := login(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
pollLoop()
}
+173
View File
@@ -0,0 +1,173 @@
// IlinkBridge.java — 微信 ClawBot iLink 最小示例(Java 11+,零第三方依赖)
// 流程: 扫码登录 → 长轮询收消息 → 回声回复
// 运行: javac IlinkBridge.java && java IlinkBridge
//
// 注意: 为保持零依赖,JSON 解析用最朴素的正则提取,仅适合演示。
// 正式项目请换 Jackson / Gson,并按 docs/04 的模块划分组织代码。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.*;
import java.util.regex.*;
public class IlinkBridge {
static final String BASE_URL = "https://ilinkai.weixin.qq.com";
static final Path STATE_FILE = Path.of("bridge-state.json");
// ---------- 状态: bot_token / 游标 / context_token(极简 key=value 存储) ----------
static Properties state = new Properties();
static void loadState() throws Exception {
if (Files.exists(STATE_FILE))
try (var in = Files.newInputStream(STATE_FILE)) { state.load(in); }
}
static void saveState() throws Exception {
try (var out = Files.newOutputStream(STATE_FILE)) { state.store(out, "ilink bridge state"); }
STATE_FILE.toFile().setReadable(false, false);
STATE_FILE.toFile().setReadable(true, true); // 近似 600
}
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
static HttpRequest.Builder req(String url) {
var b = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(50))
.header("Content-Type", "application/json")
.header("AuthorizationType", "ilink_bot_token")
.header("X-WECHAT-UIN", Base64.getEncoder().encodeToString(
Integer.toString(new SecureRandom().nextInt() >>> 1).getBytes()));
String token = state.getProperty("bot_token");
if (token != null) b.header("Authorization", "Bearer " + token);
return b;
}
static final HttpClient client = HttpClient.newHttpClient();
static String post(String path, String json) throws Exception {
var r = client.send(req(BASE_URL + path)
.POST(HttpRequest.BodyPublishers.ofString(json)).build(),
HttpResponse.BodyHandlers.ofString());
return r.body();
}
// ---------- 极简 JSON 字段提取(仅演示!正式项目用 Jackson/Gson) ----------
static String pick(String json, String key) {
var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").matcher(json);
return m.find() ? m.group(1).replace("\\\"", "\"") : null;
}
static List<String> pickAll(String json, String key) {
var out = new ArrayList<String>();
var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").matcher(json);
while (m.find()) out.add(m.group(1));
return out;
}
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
static void login() throws Exception {
var qr = client.send(req(BASE_URL + "/ilink/bot/get_bot_qrcode?bot_type=3")
.GET().build(), HttpResponse.BodyHandlers.ofString()).body();
String qrcode = pick(qr, "qrcode");
System.out.println("请用微信扫码: " + pick(qr, "qrcode_img_content"));
long deadline = System.currentTimeMillis() + 8 * 60_000;
while (System.currentTimeMillis() < deadline) {
var s = client.send(req(BASE_URL + "/ilink/bot/get_qrcode_status?qrcode=" + qrcode)
.header("iLink-App-ClientVersion", "1").GET().build(),
HttpResponse.BodyHandlers.ofString()).body();
if ("confirmed".equals(pick(s, "status"))) { // wait → scaned → confirmed
state.setProperty("bot_token", pick(s, "bot_token"));
state.setProperty("bot_id", pick(s, "ilink_bot_id"));
saveState();
System.out.println("登录成功, bot: " + state.getProperty("bot_id"));
return;
}
Thread.sleep(1000);
}
throw new RuntimeException("扫码超时");
}
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
static String askYourAI(String peerId, String text) {
// 例: 用 client 调本地 HTTP 后端 POST http://127.0.0.1:8000/chat
return "你说的是:" + text;
}
static String esc(String s) { // 最小 JSON 转义
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
}
// ---------- 接口4: POST /ilink/bot/sendmessage ----------
static void sendText(String toUserId, String text, String contextToken) throws Exception {
for (int i = 0; i < Math.max(text.length(), 1); i += 1500) { // 长文本分片
post("/ilink/bot/sendmessage", """
{"msg":{
"to_user_id":"%s",
"client_id":"%s",
"message_type":2,
"message_state":2,
"context_token":"%s",
"item_list":[{"type":1,"text_item":{"text":"%s"}}]
}}""".formatted(esc(toUserId), UUID.randomUUID(),
esc(contextToken), esc(text.substring(i, Math.min(i + 1500, text.length())))));
}
}
// ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ----------
static void pollLoop() throws Exception {
int failures = 0;
while (true) {
try {
String resp = post("/ilink/bot/getupdates",
"{\"get_updates_buf\":\"" + esc(state.getProperty("cursor", "")) + "\"," +
"\"base_info\":{\"channel_version\":\"1.0.2\"}}");
if ("-14".equals(pick(resp, "errcode"))) { // 会话过期
System.err.println("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码");
System.exit(2);
}
String buf = pick(resp, "get_updates_buf");
if (buf != null && !buf.isEmpty()) { // 游标立即落盘
state.setProperty("cursor", buf);
saveState();
}
// 演示级提取: 逐条消息的 from_user_id / context_token / text
// (正则无法严谨配对嵌套结构,正式项目务必用 JSON 库按 msgs[] 解析)
var peers = pickAll(resp, "from_user_id");
var tokens = pickAll(resp, "context_token");
var texts = pickAll(resp, "text");
for (int i = 0; i < peers.size() && i < tokens.size(); i++) {
String text = i < texts.size() ? texts.get(i) : "";
if (text.isBlank()) continue;
state.setProperty("ctx." + peers.get(i), tokens.get(i));
saveState();
try {
sendText(peers.get(i), askYourAI(peers.get(i), text), tokens.get(i));
} catch (Exception e) {
System.err.println("处理消息失败: " + e);
}
}
failures = 0;
} catch (java.net.http.HttpTimeoutException e) {
continue; // 长轮询正常超时
} catch (Exception e) {
failures++;
long wait = failures <= 3 ? 2000 : 30_000; // 退避: 2s ×3 → 30s
System.err.println("轮询异常(" + failures + "次)," + wait / 1000 + "s 后重试: " + e);
Thread.sleep(wait);
}
}
}
public static void main(String[] args) throws Exception {
loadState();
if (state.getProperty("bot_token") == null) login();
pollLoop();
}
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# ilink_bridge.py — 微信 ClawBot iLink 最小示例(Python)
# 流程: 扫码登录 → 长轮询收消息 → 回声回复
# 依赖: pip install requests
import json, os, time, uuid, base64, random, requests
BASE_URL = "https://ilinkai.weixin.qq.com"
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge-state.json")
# ---------- 状态持久化: bot_token / 游标 / context_token ----------
state = {"bot_token": None, "cursor": "", "contexts": {}}
if os.path.exists(STATE_FILE):
state.update(json.load(open(STATE_FILE)))
def save_state():
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
os.chmod(STATE_FILE, 0o600)
# ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
def headers():
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode()).decode(),
}
if state["bot_token"]:
h["Authorization"] = f"Bearer {state['bot_token']}"
return h
def api_post(path, body, timeout=45):
return requests.post(BASE_URL + path, headers=headers(), json=body, timeout=timeout).json()
# ---------- 接口1: GET /ilink/bot/get_bot_qrcode 获取登录二维码 ----------
# ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态 ----------
def login():
qr = requests.get(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3").json()
print("请用微信扫码:", qr["qrcode_img_content"])
deadline = time.time() + 8 * 60
while time.time() < deadline:
s = requests.get(f"{BASE_URL}/ilink/bot/get_qrcode_status",
params={"qrcode": qr["qrcode"]},
headers={"iLink-App-ClientVersion": "1"}).json()
if s.get("status") == "confirmed": # 状态流转: wait → scaned → confirmed
state.update(bot_token=s["bot_token"], bot_id=s["ilink_bot_id"],
owner_id=s["ilink_user_id"])
save_state()
print("登录成功, bot:", s["ilink_bot_id"])
return
time.sleep(1)
raise RuntimeError("扫码超时")
# ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
def ask_your_ai(peer_id: str, text: str) -> str:
# 例: requests.post("http://127.0.0.1:8000/chat",
# json={"session_id": peer_id, "text": text}).json()["reply"]
return f"你说的是:{text}"
# ---------- 接口4: POST /ilink/bot/sendmessage 发送消息 ----------
def send_text(to_user_id, text, context_token):
for i in range(0, max(len(text), 1), 1500): # 长文本分片
api_post("/ilink/bot/sendmessage", {
"msg": {
"to_user_id": to_user_id,
"client_id": str(uuid.uuid4()), # 去重 ID
"message_type": 2, # 2 = bot 消息
"message_state": 2, # 2 = FINISH
"context_token": context_token, # 必填! 来自入站消息
"item_list": [{"type": 1, "text_item": {"text": text[i:i+1500]}}],
}
})
# ---------- 接口3: POST /ilink/bot/getupdates 长轮询收消息 ----------
def poll_loop():
failures = 0
while True:
try:
resp = api_post("/ilink/bot/getupdates", {
"get_updates_buf": state["cursor"], # 同步游标
"base_info": {"channel_version": "1.0.2"},
})
if resp.get("errcode") == -14: # 会话过期
print("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码")
raise SystemExit(2)
if resp.get("ret", 0) != 0:
raise RuntimeError(f"getupdates 错误: {resp}")
if resp.get("get_updates_buf"): # 游标立即落盘
state["cursor"] = resp["get_updates_buf"]
save_state()
for msg in resp.get("msgs") or []:
if msg.get("message_type") != 1: # 只处理用户消息
continue
text = "\n".join(
it.get("text_item", {}).get("text", "")
for it in msg.get("item_list", []) if it.get("type") == 1
).strip()
if not text:
continue
state["contexts"][msg["from_user_id"]] = msg["context_token"]
save_state()
try:
send_text(msg["from_user_id"],
ask_your_ai(msg["from_user_id"], text),
msg["context_token"])
except Exception as e:
print("处理消息失败:", e)
failures = 0
except requests.exceptions.Timeout:
continue # 长轮询正常超时
except Exception as e:
failures += 1
wait = 2 if failures <= 3 else 30 # 退避: 2s ×3 → 30s
print(f"轮询异常({failures}次),{wait}s 后重试:", e)
time.sleep(wait)
if __name__ == "__main__":
if not state["bot_token"]:
login()
poll_loop()