docs: weixin ClawBot bridge protocol and examples
This commit is contained in:
commit
4ea3957da9
13 files changed
+2158
No files matched your search
@@ -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 个源文件,最权威的协议参考) |
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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` 下载查看
|
||||
@@ -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)
|
||||
@@ -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` |
|
||||
@@ -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 |
|
||||
@@ -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 节
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user