From 4ea3957da9834f9d6bf729a337cd60616f75f9af Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 21 Aug 2026 18:15:20 +0800 Subject: [PATCH] docs: weixin ClawBot bridge protocol and examples --- README.md | 85 ++++++++ docs/01-overview.md | 98 +++++++++ docs/02-ilink-protocol.md | 256 +++++++++++++++++++++++ docs/03-messages-and-media.md | 160 ++++++++++++++ docs/04-bridge-design.md | 169 +++++++++++++++ docs/05-implementation-guide.md | 358 ++++++++++++++++++++++++++++++++ docs/06-limits-and-faq.md | 87 ++++++++ examples/README.md | 31 +++ examples/c/ilink_client.c | 203 ++++++++++++++++++ examples/cpp/ilink_client.cpp | 194 +++++++++++++++++ examples/go/main.go | 223 ++++++++++++++++++++ examples/java/IlinkBridge.java | 173 +++++++++++++++ examples/python/ilink_bridge.py | 121 +++++++++++ 13 files changed, 2158 insertions(+) create mode 100644 README.md create mode 100644 docs/01-overview.md create mode 100644 docs/02-ilink-protocol.md create mode 100644 docs/03-messages-and-media.md create mode 100644 docs/04-bridge-design.md create mode 100644 docs/05-implementation-guide.md create mode 100644 docs/06-limits-and-faq.md create mode 100644 examples/README.md create mode 100644 examples/c/ilink_client.c create mode 100644 examples/cpp/ilink_client.cpp create mode 100644 examples/go/main.go create mode 100644 examples/java/IlinkBridge.java create mode 100644 examples/python/ilink_bridge.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..45c92a1 --- /dev/null +++ b/README.md @@ -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 `,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 个源文件,最权威的协议参考) | diff --git a/docs/01-overview.md b/docs/01-overview.md new file mode 100644 index 0000000..4f67fb1 --- /dev/null +++ b/docs/01-overview.md @@ -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) diff --git a/docs/02-ilink-protocol.md b/docs/02-ilink-protocol.md new file mode 100644 index 0000000..0b73954 --- /dev/null +++ b/docs/02-ilink-protocol.md @@ -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 ` | + +## 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 +X-WECHAT-UIN: +``` + +- `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= +``` + +状态流转:`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) diff --git a/docs/03-messages-and-media.md b/docs/03-messages-and-media.md new file mode 100644 index 0000000..016bbb7 --- /dev/null +++ b/docs/03-messages-and-media.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": "", + "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` 下载查看 diff --git a/docs/04-bridge-design.md b/docs/04-bridge-design.md new file mode 100644 index 0000000..68dc167 --- /dev/null +++ b/docs/04-bridge-design.md @@ -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) diff --git a/docs/05-implementation-guide.md b/docs/05-implementation-guide.md new file mode 100644 index 0000000..0c3f413 --- /dev/null +++ b/docs/05-implementation-guide.md @@ -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` | diff --git a/docs/06-limits-and-faq.md b/docs/06-limits-and-faq.md new file mode 100644 index 0000000..30bdcdd --- /dev/null +++ b/docs/06-limits-and-faq.md @@ -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 | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4123847 --- /dev/null +++ b/examples/README.md @@ -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 节 diff --git a/examples/c/ilink_client.c b/examples/c/ilink_client.c new file mode 100644 index 0000000..0ca8194 --- /dev/null +++ b/examples/c/ilink_client.c @@ -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 +#include +#include +#include +#include +#include +#include + +#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
    >18&63]; uin_b64[o++] = T[v>>12&63]; + uin_b64[o++] = i+1
      >6&63] : '='; + uin_b64[o++] = i+2
        = 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; +} diff --git a/examples/cpp/ilink_client.cpp b/examples/cpp/ilink_client.cpp new file mode 100644 index 0000000..182603b --- /dev/null +++ b/examples/cpp/ilink_client.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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 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(); +} diff --git a/examples/go/main.go b/examples/go/main.go new file mode 100644 index 0000000..507fa6c --- /dev/null +++ b/examples/go/main.go @@ -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() +} diff --git a/examples/java/IlinkBridge.java b/examples/java/IlinkBridge.java new file mode 100644 index 0000000..fdf1c19 --- /dev/null +++ b/examples/java/IlinkBridge.java @@ -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 pickAll(String json, String key) { + var out = new ArrayList(); + 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(); + } +} diff --git a/examples/python/ilink_bridge.py b/examples/python/ilink_bridge.py new file mode 100644 index 0000000..b7d810f --- /dev/null +++ b/examples/python/ilink_bridge.py @@ -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()