feat(wechat): ClawBot 个人微信接入第一期(后端 + 绑定页)+ 双渠道设计 §8.7 + bump 0.22.0

把 zcbot 送进用户个人微信:对话 + 主动推送(简报/结果)。选官方微信 ClawBot
(iLink Bot API,零封号)先行;企业微信作渠道 B 留接口。协议全程真机实测
(scripts/probe_clawbot*.py,本人微信号在灰度内)。

核心(后端 import/编译自测过):
- core/wechat/{ilink 协议客户端, crypto 凭据加密, service 绑定CRUD+24h窗口推送
  +send_to_user 渠道抽象, inbound 长轮询管理器+回复提取}
- WeChatBotBinding 模型 + migration 0012;tools/wechat_bot.py WechatPushTool
  + agent_builder 注册(有开关才挂)
- scheduler.deliver_notify 加 wechat 通道(未送达退邮件);web/app.py lifespan
  起入站管理器 + _run_wechat_message 回调 + 5 端点;web/static/wechat_bind.html 绑定页

实测要点:每条 sendmessage 必带唯一 client_id(漏则同 token 后续被丢);context_token
24h 可复用→主动推(需用户先开口);文件 getuploadurl→AES-128-ECB(PKCS7)→CDN
(URL 带 filekey)→file_item,docx/pdf 原生直推。

激活:db upgrade head(带 0012)+ env ZCBOT_WECHAT_BOT_ENABLED=1
+ ZCBOT_WECHAT_SECRET_KEY=<串>。待办:部署端到端联调、SPA 集成绑定 UI、企业微信渠道 B。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-24 08:59:56 +08:00
parent 336db63a01
commit 528b974d9f
24 changed files with 2367 additions and 23 deletions

5
.gitignore vendored
View File

@ -52,3 +52,8 @@ col.ps1
# brief skill 临时样例输出 (可由 skill 重新生成, 不入库) # brief skill 临时样例输出 (可由 skill 重新生成, 不入库)
.brief_out/ .brief_out/
# ClawBot 接入探测临时产物 (二维码图 / 测试文件, 探测时重新生成, 不入库;
# 探测脚本 scripts/probe_clawbot*.py 保留作参考与复测)
scripts/clawbot_qr*.png
scripts/zcbot_filetest.txt

View File

@ -656,6 +656,83 @@ create index on usage_events (model_profile, created_at);
**取舍**:重构对三 profile 各渲前后 diff `word/document.xml` **字节一致**(零回归);brief 不强并进 manuscript 路径(引文/配色差异大,只共用叶子原语,降回归面)。 **取舍**:重构对三 profile 各渲前后 diff `word/document.xml` **字节一致**(零回归);brief 不强并进 manuscript 路径(引文/配色差异大,只共用叶子原语,降回归面)。
### 8.7 微信接入(双渠道:ClawBot 个人微信 + 企业微信自建应用)(2026-06-23 设计,status=design)
**诉求**:把 zcbot 送进用户**个人微信**——简报/任务结果主动推过来,且能在微信里直接跟它对话。用户体感 = 微信通讯录里多一个叫「微信 ClawBot」的**联系人**,像加了个好友一样聊。
> **⚠️ 实测结论(2026-06-23,`scripts/probe_clawbot*.py`,真机端到端;关键是 `client_id`):ClawBot 可双向对话 + 可主动推送(有前提)。**
> ① 灰度可用(扫码 `confirmed``bot_token` + `baseurl`);② **入站通**(`getupdates` 长轮询收用户消息,带 `from_user_id` + `context_token`);③ **多条/流式回复成立**——同一 `context_token` 连发多条,**每条 `msg` 必须带唯一 `client_id`**(漏它则只有第一条送达——前几轮误判"单条/纯被动"的真因),中间块 `message_state=1`(GENERATING)、末块 `=2`(FINISH),按 ~1000 字分块、各块间隔 ~300ms;④ **主动推送成立**——发完 FINISH 后隔 30s 复用同一 `context_token`(+ 新 `client_id`)仍送达,**`context_token` 有效期约 24h、可复用**。
> **故「定时简报主动推送」(本节最初核心诉求)在 ClawBot 上可行**,前提:用户**先开口过一次**(冷启动无 token 不能凭空推),且距上次互动在 token 有效期(~24h)内——**每条入站消息刷新该用户的 `context_token`**;超期未互动则需用户再开口(或退邮件兜底)。冷推(从未开口)仍不可能。
**选型:三条路,选官方 ClawBot(详见对话调研 2026-06-23)**:
- **wechaty / hook(非官方个微)** —— 逆向/注入,违反腾讯 ToS,**封号率高**(hook >80%、web 协议被大量封),要养号/同省 IP/限速。**排除**。
- **企业微信自建应用** —— 官方、稳定;①只触达**企业微信成员**(非个人微信);②要企业**管理员**建应用 + 配可信域名;③双向对话要回调 + AES + 5s ACK,重。但**主动推送无条件**(不挑用户活跃度、不依赖灰度)→ 定时简报"必达"首选。**与 ClawBot 并列为第二渠道(本节一并设计,见下「渠道 B」),共用渠道抽象。**
- **微信 ClawBot(iLink Bot API)** —— 腾讯 2026-03-22 官方上线,跑在官方 iLink 协议 + 官方服务器 `ilinkai.weixin.qq.com`,**零封号**;腾讯定位"管道",**后端接谁都行**(可接 zcbot)。**采用**。
**为什么先实现 ClawBot(企业微信紧随)**:零管理员(用户自扫,不建应用/不配域名)→ 能立即跑通验证(协议已真机实测全通);企业微信要等管理员建应用 + 配可信域名的资源到位。企业微信随后补上,用其**无条件推送**补 ClawBot 的"24h 活跃才可推"短板。
**渠道抽象(两渠道共用,加渠道不改 scheduler / 工具主体)**:
- **绑定**:per-user 记"绑了哪些渠道 + 各自凭据/标识"(ClawBot:`bot_token`+`latest_context_token`;企业微信:`wecom_userid`,应用凭据走全局 env)。
- **统一发送**:`send_to_user(user_id, text, file?)` → 解析该用户已绑渠道 → 各渠道实现各自发;`scheduler.deliver_notify`、`WechatPushTool` 都调这层,不感知具体渠道。
- **推送择优**:简报这类"必达" → 优先企业微信(无条件);ClawBot 作个人微信触达 + 聊天;两者都绑可多投或按用户偏好。
**第一期两处已定决策(评审通过)**:
- **入站对话 → 每用户一条 persistent「微信」task**(聊天要连续性;token 增长靠 §8.2 context 压缩;打标签与网页 task 区分)。**两渠道入站都落到这条 task**。
- **敏感凭据入库一律加密列**(`bot_token`/`latest_context_token`;企业微信 secret 走 env 不入库)——env `ZCBOT_WECHAT_SECRET_KEY` 派生密钥;绝不进沙箱/日志/API 响应(§3.4)。
**唯一现实卡点 = 微信灰度可用性**:仅**国内个人微信**、需 **8.0.70+** 且功能灰度推送中(设置→插件),**不支持企业微信**(`bot_type=3`)。目标用户没有插件入口就用不了——落地前要先核实目标用户在灰度内。腾讯另保留**限频 / 决定可连哪些 AI / 随时终止**的权力(政策风险)。
**注册门槛 ≈ 零**:`get_bot_qrcode` **无需任何预置 app_id/凭据/审核/费用**,任何后端直接调即可生成二维码;`bot_token` 纯靠用户扫码下发。**能完全脱离 OpenClaw 自实现**协议客户端(社区 `weixin-ClawBot-API` 已证)。
**绑定模型(沿用前版已对的 per-user 扫码骨架)**:
- 每个 zcbot 用户**扫一次码** → 后端拿到**该用户专属 `bot_token`**(Bot ID `xxx@im.bot` / User ID `xxx@im.wechat`)→ 存库 → 之后按用户收发。**1 个 bot_token 对应 1 个微信账号**(扫码者)。
- 这与"每个用户连自己的微信"天然吻合,且**零管理员**(对比企业微信省掉建应用 + 可信域名)。
- ⚠️ **待核实**:`bot_token` 是 1:1(每用户一条、各自一条长轮询)还是 1:N(单 token 多用户、靠消息内 `@im.wechat` 区分,Telegram 式)。设计**按更确定的 1:1** 落,若实测为 1:N 则简化为单循环。
**扫码绑定流程(iLink)**:
1. zcbot 网页"绑定微信" → 后端 `GET get_bot_qrcode?bot_type=3``{qrcode, qrcode_img_content}`,前端展示二维码。
2. 后端 `GET get_qrcode_status?qrcode=<id>`(长轮询,单连 hold ≤35s,循环续)→ 用户用**个人微信**扫码确认 → 返回 `{status:'confirmed', bot_token, baseurl}`
3. 把当前登录 zcbot user 与返回的 `bot_token/baseurl/user_im_id` upsert 进 `wechat_bot_bindings`。前端轮询自己的绑定状态翻转。
**数据模型(新表 `wechat_bot_bindings`,独立加表 → 公测兼容)**:
`user_id(PK/FK→users), bot_token(敏感,长期), bot_im_id, user_im_id(xxx@im.wechat), base_url, latest_context_token(敏感,入站时刷新), context_token_at(时间戳,判 24h 推送窗口), status(active|revoked), created_at, updated_at`。migration `0012_wechat_bot_bindings`。`bot_token` / `latest_context_token` 是**敏感凭据**:加密列存或至少**绝不进沙箱 / 不落日志**(沿用 §3.4 密钥隔离);不进 run_python。
**协议要点(自实现客户端,2026-06-23 实测验证)**:base = 绑定返回的 `base_url`(实测 `https://ilinkai.weixin.qq.com`)。所有请求 header:`Content-Type: application/json` + `AuthorizationType: ilink_bot_token` + **`X-WECHAT-UIN` 每请求变**(`base64(随机uint32)`,反重放);除取码/查状态外加 `Authorization: Bearer <bot_token>`
- **取码/绑定**:`GET /ilink/bot/get_bot_qrcode?bot_type=3`(无需任何预置凭据)→ `{qrcode, qrcode_img_content}`,`qrcode_img_content` 是**微信深链**(`liteapp.weixin.qq.com/q/...`),需**自渲成二维码**(非图片直链);`GET /ilink/bot/get_qrcode_status?qrcode=`(长轮询)→ `{status: wait|confirmed|expired, bot_token, baseurl}`。二维码 TTL 短(~1min),实现要**过期自动换码**。
- **收**:`POST /ilink/bot/getupdates`,body `{get_updates_buf:<游标,首次空>, base_info:{channel_version:"1.0.2"}}`(长轮询 hold ≤35s)→ `{msgs:[{from_user_id, context_token, item_list:[{type:1,text_item:{text}}]}], get_updates_buf}`
- **发**:`POST /ilink/bot/sendmessage`,body `{msg:{to_user_id, client_id:<每条唯一>, message_type:2, message_state:1|2, context_token, item_list:[...]}, base_info:{channel_version:"1.0.2"}}`。**`client_id` 必带且每条唯一**(否则同 token 后续消息被丢);多条/长文 → 中间块 `message_state=1`、末块 `=2`,~1000 字/块、间隔 ~300ms。成功返回 HTTP 200 + 空 body `{}`(无 ret,不能据 body 判成败,以实投为准)。
- **token 生命周期**:`context_token` 有效期 ~24h、可复用(发完 FINISH 仍可再发)→ 主动推送靠它;**每条入站消息刷新**该用户 token(存最新值 + 时间戳)。`bot_token` 长期 per-user 凭据(扫码下发)。
- **文件发送(2026-06-23 实测通,`scripts/probe_clawbot_file.py`)**:①`POST /ilink/bot/getuploadurl`(body `{filekey:随机16B的hex, media_type:3(FILE)/1(IMAGE), to_user_id, rawsize, rawfilemd5, filesize:PKCS7填充后大小, aeskey:随机16B的hex, no_need_thumb:true, base_info}`)→ 返回 `{upload_param}`;② 本地用该 aeskey 做 **AES-128-ECB + PKCS7** 加密文件;③ `POST {cdn_base}/upload?encrypted_query_param=<urlenc(upload_param)>&filekey=<urlenc(filekey)>`(`cdn_base=https://novac2c.cdn.weixin.qq.com/c2c`,body=密文、`application/octet-stream`)→ **响应头 `x-encrypted-param`** = 下载引用(漏 `&filekey=` 会 400 `filekey mismatch`);④ `sendmessage``item_list:[{type:4, file_item:{media:{encrypt_query_param:<上一步 x-encrypted-param>, aes_key:base64(aeskey.hex()的ascii字节), encrypt_type:1}, file_name, len:str(rawsize)}}]`。**docx/pdf 简报可原生直推为可打开附件**,无须退下载链接。
- ⚠️ **仍待核实**:富文本(markdown)渲染支持度(源码有 `markdown-filter.ts`,暂按纯文本正文 + 文件直推设计);限频数值(腾讯保留限速);媒体大小上限(暂沿用 20MB)。
**架构:入站与出站一体(第一期一起做)** —— **主动推送依赖 `context_token`,而 token 只能从入站消息拿**,故"只出站不入站"不成立;getupdates 长轮询既收对话、又负责刷新 token。
- **入站长轮询管理器**(lifespan 起,仿 §8.4 `_disk_scanner` plain-asyncio):每个 active binding 一条 `getupdates`(hold ≤35s 循环续)。收到消息 → 按 `bot_token`→binding→zcbot `user_id` 定位是谁 → **刷新该 binding 的 `latest_context_token` + 时间戳** → 映射到该用户的微信对话 task(默认一条 persistent「微信」task 保连续性,§8.5 会话模式)→ 复用 `_run_agent_bg` 跑 → 结果按 ~1000 字分块 `sendmessage`(每块新 `client_id`、中间 `state=1``state=2`)带 `context_token` 回。**无 5s ACK 约束**,长 run 天然 OK——相对企业微信回调的根本简化。
- **出站主动推送**(scheduler 简报 / 任务结果 / `WechatPushTool`):用库里该用户 `latest_context_token`,**距上次入站 <~24h** 则直接 `sendmessage`(文本 + docx/pdf 文件直推);**超期 / 从未开口** → 推不出,退邮件兜底(§8.5)或挂起待用户下次开口刷新 token。即"用户开口过、且近 24h 活跃 → 可主动推"。
- **scale**:N 个 active binding = N 条长轮询;公测期 N 小可接受;放大时视 1:1/1:N 实测结果改为单循环轮询多 token。
**接入面(复用现有范式)**:
1. `tools/wechat_bot.py`:ClawBot 客户端(`get_bot_qrcode/get_qrcode_status/getupdates/sendmessage` + AES 媒体)+ `wechat_bot_enabled()`(开关在才挂工具,沿用 §3.4)+ `resolve_wechat_target(user_id)`→`bot_token` + `WechatPushTool`(agent 可调,按当前 run 的 user_id 解析)。HTTP 走已有 httpx。
2. `core/scheduler.py` `deliver_notify``channel=="wechat"` 分支,与 email 并列 → 定时简报**把最新产物文件直推**本人微信(取 `_newest_artifact`,≤上限 `sendmessage` 文件、超限退"点此下载"链接;**不改 job schema**——通道是 notify 字段的值)。
3. `web/app.py`:`POST /v1/wechat/bind/qrcode`(起二维码)、`GET /v1/wechat/bind/status`(轮询绑定结果)、`DELETE /v1/wechat/bind`(解绑)、`POST /v1/wechat/test`(自检发一条);**lifespan 起入站长轮询管理器**(见上"架构");前端设置加"绑定微信"扫码 UI。
**渠道 B:企业微信自建应用(紧随 ClawBot 实现,共用渠道抽象)**
- **应用凭据(全局 env,需管理员建应用)**:`WECOM_CORPID / WECOM_AGENTID / WECOM_SECRET`;secret 仅 host 进程读、不进沙箱(同 ClawBot / `send_email`)。host 直连 `qyapi.weixin.qq.com`
- **扫码绑定(OAuth 网页授权)**:网页"绑定企业微信" → `open.weixin.qq.com/connect/oauth2/authorize?...scope=snsapi_base&agentid=&state=<HMAC签+短TTL>#wechat_redirect` → 桌面出二维码扫 / 企业微信内静默 → 回调 `cgi-bin/auth/getuserinfo?code=``wecom_userid` → 写绑定。**需管理员另配「网页授权可信域名」指向 zcbot 域名**。
- **推送(无条件主动推,核心优势)**:`gettoken(corpid,secret)` → `access_token`(2h 进程内缓存 + 提前刷新 + 线程安全锁)→ `message/send` 发 text/markdown/**file**(file 先 `media/upload?type=file` 换临时 `media_id`,≤20MB)。**不需用户先开口、无 24h 窗口** → 定时简报必达首选。
- **入站对话(第二期)**:自建应用配「接收消息回调」+ AES 解密(`WXBizMsgCrypt`)+ 5s ACK + 异步跑 agent + 主动回推;复杂度高,后置。
- **数据**:绑定抽象加企业微信侧(`wecom_userid`;多企业留 nullable `corpid/permanent_code` 走服务商 ISV,additive);migration `0013_wecom_binding`(或并入同表多列)。
- **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。
**取舍(不选)**:
- **不用 wechaty/hook**:违规 + 高封号 + 养号运维,机构产品不可接受。
- **第一期不锁企业微信**:企业微信触达面窄(仅成员)、要管理员、双向重;ClawBot 触达个人微信 + 零管理员 + 双向轻。企业微信留作"机构身份 / 不依赖灰度"的后续备选,与本通道正交、绑定表/推送抽象可平行扩。
- **bot_token 落库但隔离**:它是长期 per-user 凭据,必须持久化(不同于企业微信 2h `access_token` 可纯内存);安全靠加密列 + 不进沙箱,不靠不落库。
- **富排版不强求卡片**:个微富文本能力存疑,统一走"正文纯文本 + 产物文件直推",规避平台差异。
**改动面(第一期,含入站+出站)**:1 张新表 + migration `0012_wechat_bot_bindings`;`tools/wechat_bot.py`(iLink 客户端 + `WechatPushTool` + 绑定/token 服务);**1 个 lifespan 入站长轮询管理器 + 消息→user/task 映射**(复用 `_run_agent_bg`);`core/scheduler.py` `deliver_notify``wechat` 分支;`web/app.py` 4 端点 + 前端扫码 UI;agent_builder 注册(开关在才挂)。env:`ZCBOT_WECHAT_BOT_ENABLED`(+ 可选 `ZCBOT_WECHAT_BASE_URL` 覆盖)+ `ZCBOT_WECHAT_SECRET_KEY`(凭据加密)——**无全局 app secret**(凭据是 per-user `bot_token`,扫码下发)。**不动** loop/llm/capabilities/现有 schema。
**渠道 B(企业微信,紧随)改动面**:env `WECOM_CORPID/AGENTID/SECRET`;`tools/wecom_push.py`(access_token 缓存 + `message/send` + `media/upload` + 渠道实现);`send_to_user` / `deliver_notify` 接 wecom 渠道;绑定抽象加 wecom 侧 + migration `0013`;OAuth 起始/回调 2 端点 + 前端"绑定企业微信"。**两渠道共用 `send_to_user` 抽象与绑定层**,故渠道 B 主要是"多一个渠道实现 + 一种绑定方式",不重写主体。
--- ---
## 附录:DeepSeek V4 关键事实(2026-04-24) ## 附录:DeepSeek V4 关键事实(2026-04-24)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-23(平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf + bump 0.21.0) 最后更新:2026-06-24(微信接入第一期 ClawBot 后端 + 自包含绑定页;双渠道设计 §8.7 + bump 0.22.0)
--- ---
@ -21,6 +21,15 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-24 / 微信接入第一期:ClawBot 个人微信(后端完成,bump 0.22.0)
- 需求:把 zcbot 送进用户**个人微信**——能对话、能推简报/结果。调研三条路:wechaty/hook(违规高封号,排除)、企业微信自建应用(官方但要管理员+仅企业成员)、**微信 ClawBot**(腾讯 2026-03 官方个人号 Bot API,iLink 协议,零封号,后端接谁都行)。选 ClawBot 先行。详 DESIGN §8.7。
- **协议全程真机实测**(`scripts/probe_clawbot*.py`,本人微信号在灰度内):① 扫码绑定拿 `bot_token`;② `getupdates` 长轮询收消息;③ `sendmessage` **每条 `client_id` 必唯一**(漏则同 token 后续被丢——前几轮误判"纯被动"的真因),多条/长文中间块 `state=1` 末块 `state=2`;④ `context_token` 24h 可复用 → **主动推送成立**(需用户先开口一次);⑤ 文件:`getuploadurl`→AES-128-ECB(PKCS7)→CDN(URL 带 `filekey`,漏则 400 mismatch)→`file_item`,docx/pdf 原生直推。
- **关键设计决策**:入站对话→每用户一条 persistent「微信」task(连续性,token 靠 §8.2 压缩);凭据(bot_token/context_token)加密列(env `ZCBOT_WECHAT_SECRET_KEY`),绝不进沙箱/日志;**入站出站一体**——主动推送依赖入站给的 context_token,故 getupdates 长轮询常驻(既收对话又刷新 24h 窗口)。
- **文件**(后端全部 import/编译自测过):`core/wechat/{ilink.py 协议客户端, crypto.py 凭据加密, service.py 绑定CRUD+推送+send_to_user 渠道抽象, inbound.py 长轮询管理器+回复提取}`;`core/storage/models.py` 加 `WeChatBotBinding` + migration `0012_wechat_bot_bindings`;`tools/wechat_bot.py` `WechatPushTool` + `core/agent_builder.py` 注册(有开关才挂);`core/scheduler.py` `deliver_notify``wechat` 通道(未送达退邮件兜底);`web/app.py` lifespan 起入站管理器 + `_run_wechat_message` 回调 + 5 端点(`/v1/wechat/bind/qrcode|status`、`/v1/wechat/bind` GET/DELETE、`/v1/wechat/test`);`web/static/wechat_bind.html` 自包含绑定页;`requirements.txt` 加 segno+cryptography。
- **env**:`ZCBOT_WECHAT_BOT_ENABLED=1`(渠道开关)+ `ZCBOT_WECHAT_SECRET_KEY=<串>`(凭据加密,缺则退明文标记)+ 可选 `ZCBOT_WECHAT_BASE_URL`
- **待办(部署后联调)**:migration `0012` 上库;起 web 进程端到端验(扫码绑定→对话→主动推→定时简报推);**SPA 集成**绑定 UI(当前是独立 `/static/wechat_bind.html`,后续并入主 SPA 设置,仿 crons.js modal 范式);**渠道 B 企业微信**(无条件推送,补 ClawBot 24h 窗口短板)按 §8.7「渠道 B」实现。
### 2026-06-23 / 平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf(bump 0.21.0) ### 2026-06-23 / 平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf(bump 0.21.0)
- 背景:线上 `简报` task 用户要"输出为pdf",模型因 brief 无 PDF 路径而临场即兴——试 `apt install libreoffice`(只读 fs 失败)→ `pip install weasyprint markdown` 手搓 md→HTML→weasyprint;容器空闲回收后包不持久,二次导出又重装一遍。深挖发现两个问题:① skill 缺 PDF 路径、weasyprint 不在镜像;② `_CHEM_RE` 化学式白名单在 brief/paper/proposal **三份 render_docx.py 逐字重复**(改一处易漏改),patent/standard 还复用 proposal 那份。 - 背景:线上 `简报` task 用户要"输出为pdf",模型因 brief 无 PDF 路径而临场即兴——试 `apt install libreoffice`(只读 fs 失败)→ `pip install weasyprint markdown` 手搓 md→HTML→weasyprint;容器空闲回收后包不持久,二次导出又重装一遍。深挖发现两个问题:① skill 缺 PDF 路径、weasyprint 不在镜像;② `_CHEM_RE` 化学式白名单在 brief/paper/proposal **三份 render_docx.py 逐字重复**(改一处易漏改),patent/standard 还复用 proposal 那份。

7
RUN.md
View File

@ -55,9 +55,14 @@
# ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON) # ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)
# ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏) # ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏)
# ZCBOT_SCHEDULER_CONCURRENCY=4 # 可选,并发跑的定时 run 上限,默 4 # ZCBOT_SCHEDULER_CONCURRENCY=4 # 可选,并发跑的定时 run 上限,默 4
# 微信接入(ClawBot 个人微信,DESIGN §8.7):可选。开关在才挂 wechat_push tool + 起入站长轮询。
# ZCBOT_WECHAT_BOT_ENABLED=1 # 渠道总开关;开启后 lifespan 起入站管理器,用户可扫码绑定
# ZCBOT_WECHAT_SECRET_KEY=<随机串> # 凭据(bot_token/context_token)列加密密钥;缺则退明文标记(公测兜底)
# ZCBOT_WECHAT_BASE_URL=... # 可选,覆盖 iLink base(默 https://ilinkai.weixin.qq.com)
``` ```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...` > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。
- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env``ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后开 `/static/wechat_bind.html` 扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。绑定页目前独立、未并入主 SPA。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 - **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.21.0" __version__ = "0.22.0"

View File

@ -60,6 +60,7 @@ from tools.schedule import (
ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool, ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool,
) )
from tools.send_email import SendEmailTool, smtp_configured from tools.send_email import SendEmailTool, smtp_configured
from tools.wechat_bot import WechatPushTool, wechat_push_available
from core.ark_client import ArkConfig from core.ark_client import ArkConfig
from core.bocha_client import BochaConfig from core.bocha_client import BochaConfig
@ -568,6 +569,12 @@ def build_agent(
se = SendEmailTool(base_dir=tool_base, user_root=ur_path) se = SendEmailTool(base_dir=tool_base, user_root=ur_path)
tools[se.name] = se tools[se.name] = se
# 微信主动推送(§8.7 渠道抽象):仅当微信渠道开关在才挂(沿用"有开关才注册")。
# 交互与定时 run 都可用(定时简报可主动推回用户微信,24h 窗口内)。user_id ctor 注入。
if wechat_push_available():
wp = WechatPushTool(uid, base_dir=tool_base, user_root=ur_path)
tools[wp.name] = wp
if caps.enable_run_python: if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp tools[rp.name] = rp

View File

@ -182,27 +182,8 @@ def _newest_artifact(working_dir: Path) -> Optional[Path]:
return best return best
def deliver_notify( def _notify_email(to, job_name: str, when: str, artifact: Optional[Path]) -> None:
notify: Optional[dict[str, Any]],
*,
job_name: str,
working_dir: Path,
tz: str,
) -> None:
"""job 配了 notify 就确定性补发(不靠 agent 记性)。目前仅 email 通道:
把工作目录最新产物当附件,套固定模板发无产物则发纯文本告知已执行
阻塞 IO(smtplib),由编排层放进 run_in_executor 失败抛异常,编排层吞掉记日志
"""
if not notify or notify.get("channel") != "email":
return
to = notify.get("to")
if not to:
return
from tools.send_email import send_email_smtp # 延迟导入,避免 core→tools 顶层环依赖 from tools.send_email import send_email_smtp # 延迟导入,避免 core→tools 顶层环依赖
when = datetime.now(_tzinfo(tz)).strftime("%Y-%m-%d %H:%M")
artifact = _newest_artifact(working_dir)
if artifact is not None: if artifact is not None:
subject = f"[定时任务] {job_name} · {when}" subject = f"[定时任务] {job_name} · {when}"
body = f"定时任务「{job_name}」已于 {when} 执行,产物见附件:{artifact.name}" body = f"定时任务「{job_name}」已于 {when} 执行,产物见附件:{artifact.name}"
@ -213,6 +194,51 @@ def deliver_notify(
send_email_smtp(to, subject, body) send_email_smtp(to, subject, body)
def deliver_notify(
notify: Optional[dict[str, Any]],
*,
job_name: str,
working_dir: Path,
tz: str,
user_id: Optional[Any] = None,
) -> None:
"""job 配了 notify 就确定性补发(不靠 agent 记性)。通道:
- `email`:把工作目录最新产物当附件发到 notify.to
- `wechat`:把最新产物 + 一句话主动推到该用户已绑微信(§8.7);未送达( 24h 窗口 /
未绑 / 未开口) notify 配了 `to`(邮箱)+ SMTP 退邮件兜底,否则抛错
阻塞 IO(smtplib / httpx),由编排层放进 run_in_executor 失败抛异常,编排层吞掉记日志
"""
if not notify:
return
channel = notify.get("channel")
when = datetime.now(_tzinfo(tz)).strftime("%Y-%m-%d %H:%M")
artifact = _newest_artifact(working_dir)
if channel == "email":
to = notify.get("to")
if to:
_notify_email(to, job_name, when, artifact)
return
if channel == "wechat":
if user_id is None:
return
from core.wechat.service import send_to_user # 延迟导入,避免顶层环依赖
from tools.send_email import smtp_configured
text = (f"定时任务「{job_name}」已于 {when} 执行"
+ (f",产物:{artifact.name}" if artifact else ",本次未产生文件产物。"))
report = send_to_user(user_id, text, str(artifact) if artifact else None)
if report.delivered:
return
fb = notify.get("to") # 可选 fallback 邮箱
if fb and smtp_configured():
_notify_email(fb, job_name, when, artifact)
return
raise RuntimeError("微信推送未送达: " + ", ".join(r.reason for r in report.results))
# ───────────── CRUD 服务层(对话工具 + REST 端点共用,DESIGN §8.5)───────────── # ───────────── CRUD 服务层(对话工具 + REST 端点共用,DESIGN §8.5)─────────────
# #
# tools/schedule.py(对话)与 web/app.py 的 /v1/schedules(前端只读+停用/删除)都调 # tools/schedule.py(对话)与 web/app.py 的 /v1/schedules(前端只读+停用/删除)都调

View File

@ -225,3 +225,44 @@ class ScheduledJob(Base):
) )
class WeChatBotBinding(Base):
"""ClawBot 个人微信绑定(0012,DESIGN §8.7 渠道 A)。
一行 = 一个 zcbot 用户绑定其个人微信微信 ClawBotPK=user_id 1 用户 1 绑定
- `bot_token`:扫码下发的长期 per-user 凭据
- `latest_context_token` + `context_token_at`:每条入站消息刷新;主动推送靠它,
仅在 ~24h 有效期内可用(冷启动 / 超期则推不出,退邮件兜底,§8.5)
两个 token 列存**密文**(core/wechat/crypto.py; ZCBOT_WECHAT_SECRET_KEY 时退明文标记)
绝不进沙箱 / 日志 / API 响应(§3.4)
"""
__tablename__ = "wechat_bot_bindings"
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("users.user_id", ondelete="CASCADE"),
primary_key=True,
)
bot_token: Mapped[str] = mapped_column(Text, nullable=False) # 密文
bot_im_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # xxx@im.bot
user_im_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # xxx@im.wechat
base_url: Mapped[str] = mapped_column(
Text, nullable=False, server_default="https://ilinkai.weixin.qq.com"
)
latest_context_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 密文,入站刷新
context_token_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 该用户的微信对话常驻 task(persistent,§8.7 决策);两渠道入站都落这条
chat_task_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default="active") # active|revoked
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

6
core/wechat/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""微信接入(DESIGN §8.7)。
渠道 A = ClawBot 个人微信 iLink Bot API(`ilink.py`,协议已真机实测,
`scripts/probe_clawbot*.py`);渠道 B = 企业微信自建应用(后续 `wecom.py`)
本包只放协议客户端等纯逻辑, DB / agent 编排解耦
"""

59
core/wechat/crypto.py Normal file
View File

@ -0,0 +1,59 @@
"""敏感凭据的列加密(DESIGN §8.7:bot_token / latest_context_token 加密入库)。
- env `ZCBOT_WECHAT_SECRET_KEY` 用其派生的 Fernet 密钥加密,密文带 `v1:` 前缀
- env 不在 退明文标记`plain:`(公测兜底,日志/沙箱/API 仍绝不带这两列;
正式部署应配 key)`enc()`/`dec()` 对两种前缀都可逆, key 不影响存量明文行
只在 host 进程(绑定服务 / 入站管理器 / push);绝不进沙箱 / run_python
"""
from __future__ import annotations
import base64
import hashlib
import os
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
_PREFIX_ENC = "v1:"
_PREFIX_PLAIN = "plain:"
def _fernet() -> Optional[Fernet]:
key = os.getenv("ZCBOT_WECHAT_SECRET_KEY", "").strip()
if not key:
return None
# 任意口令 → 32B → urlsafe-base64 Fernet 密钥(确定性,免单独管 Fernet key)
digest = hashlib.sha256(key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def enc(plaintext: Optional[str]) -> Optional[str]:
"""明文 → 入库串。配了 key 走密文(v1:),否则明文标记(plain:)。None 透传。"""
if plaintext is None:
return None
f = _fernet()
if f is None:
return _PREFIX_PLAIN + plaintext
token = f.encrypt(plaintext.encode("utf-8")).decode("ascii")
return _PREFIX_ENC + token
def dec(stored: Optional[str]) -> Optional[str]:
"""入库串 → 明文。识别 v1:/plain: 前缀;v1: 需 key 且匹配。None 透传。"""
if stored is None:
return None
if stored.startswith(_PREFIX_PLAIN):
return stored[len(_PREFIX_PLAIN):]
if stored.startswith(_PREFIX_ENC):
f = _fernet()
if f is None:
raise RuntimeError(
"密文需要 ZCBOT_WECHAT_SECRET_KEY 才能解密,但 env 未配置"
)
try:
return f.decrypt(stored[len(_PREFIX_ENC):].encode("ascii")).decode("utf-8")
except InvalidToken as e:
raise RuntimeError("ZCBOT_WECHAT_SECRET_KEY 与密文不匹配(key 变了?)") from e
# 无前缀:历史/手填的裸明文,容错原样返回
return stored

297
core/wechat/ilink.py Normal file
View File

@ -0,0 +1,297 @@
"""ClawBot 个人微信 iLink Bot API 客户端(DESIGN §8.7 渠道 A)。
协议全部经真机实测(`scripts/probe_clawbot*.py`,2026-06-23):
- 绑定:`get_bot_qrcode`(无凭据,出深链 自渲二维码) 轮询 `get_qrcode_status`
(TTL ~1min,过期换码) `confirmed` `bot_token` + `baseurl`
- :`getupdates` 长轮询(hold 35s),消息带 `from_user_id` + `context_token`
- :`sendmessage`,**每条 `client_id` 必唯一**(漏则同 token 后续被丢);多条/长文
~1000 字分块,中间 `message_state=GENERATING(1)`末块 `FINISH(2)`,间隔 ~300ms
- `context_token` 有效期 ~24h可复用 主动推送靠它(用户须先开口拿到 token)
- 文件:`getuploadurl` AES-128-ECB(PKCS7)加密 POST 密文到 CDN `x-encrypted-param`
`sendmessage` `file_item`
纯协议客户端,不碰 DB / agent 编排阻塞 IO(httpx 同步),调用方放 to_thread / executor
"""
from __future__ import annotations
import base64
import hashlib
import os
import time
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from urllib.parse import quote
import httpx
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
DEFAULT_BASE = "https://ilinkai.weixin.qq.com"
CDN_BASE = "https://novac2c.cdn.weixin.qq.com/c2c"
CHANNEL_VERSION = "1.0.2"
BOT_TYPE_PERSONAL = 3
# 协议枚举(源码 @tencent-weixin/openclaw-weixin src/api/types.ts,已实测)
MSG_TYPE_BOT = 2
STATE_GENERATING = 1
STATE_FINISH = 2
ITEM_TEXT = 1
ITEM_IMAGE = 2
ITEM_FILE = 4
UPLOAD_MEDIA_FILE = 3
UPLOAD_MEDIA_IMAGE = 1
# 分块:长文按 ~1000 字切,块间隔防丢
CHUNK_CHARS = 1000
CHUNK_DELAY_S = 0.3
MAX_FILE_BYTES = 20 * 1024 * 1024
def _uin_header() -> str:
"""X-WECHAT-UIN:base64(随机 uint32 的十进制字符串),反重放,每请求变。"""
n = int.from_bytes(os.urandom(4), "big")
return base64.b64encode(str(n).encode()).decode()
def _headers(bot_token: Optional[str] = None) -> dict[str, str]:
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin_header(),
}
if bot_token:
h["Authorization"] = f"Bearer {bot_token}"
return h
def _base_info() -> dict[str, str]:
return {"channel_version": CHANNEL_VERSION}
def _new_client_id() -> str:
return f"openclaw-weixin-{uuid.uuid4().hex}"
def _aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes:
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
return enc.update(padded) + enc.finalize()
# ─────────────────────────── 绑定(无 token)───────────────────────────
@dataclass
class QrCode:
qrcode_id: str
deeplink: str # liteapp.weixin.qq.com/q/...,调用方自渲成二维码图片
def get_bot_qrcode(base_url: str = DEFAULT_BASE, *, timeout: float = 20.0) -> QrCode:
"""取一张绑定二维码。无需任何预置凭据。`deeplink` 需自渲成二维码让用户扫。"""
with httpx.Client(timeout=timeout) as c:
r = c.get(
f"{base_url}/ilink/bot/get_bot_qrcode",
params={"bot_type": BOT_TYPE_PERSONAL},
headers=_headers(),
)
r.raise_for_status()
d = r.json()
return QrCode(qrcode_id=d.get("qrcode", ""), deeplink=d.get("qrcode_img_content", ""))
@dataclass
class BindResult:
status: str # wait | confirmed | expired
bot_token: Optional[str] = None
base_url: Optional[str] = None
def poll_qrcode_status(
qrcode_id: str, base_url: str = DEFAULT_BASE, *, timeout: float = 40.0
) -> BindResult:
"""单次轮询扫码状态(服务端长轮询,hold 数十秒)。调用方循环调用,
`expired` 重新 `get_bot_qrcode` 换码`confirmed` 时返回 bot_token + base_url"""
with httpx.Client(timeout=timeout) as c:
r = c.get(
f"{base_url}/ilink/bot/get_qrcode_status",
params={"qrcode": qrcode_id},
headers=_headers(),
)
r.raise_for_status()
d = r.json()
return BindResult(
status=d.get("status", ""),
bot_token=d.get("bot_token"),
base_url=d.get("baseurl") or d.get("base_url"),
)
# ─────────────────────────── 收发(带 token)───────────────────────────
@dataclass
class InboundMessage:
from_user_id: str # xxx@im.wechat
context_token: str # 回复 / 24h 内主动推须带回
text: str
raw: dict[str, Any]
class ILinkClient:
"""绑定后按用户持有 `bot_token` + `base_url`,收发该用户消息。"""
def __init__(self, bot_token: str, base_url: str = DEFAULT_BASE) -> None:
self.bot_token = bot_token
self.base_url = base_url or DEFAULT_BASE
# —— 收 ——
def get_updates(
self, cursor: str = "", *, timeout: float = 45.0
) -> tuple[list[InboundMessage], str]:
"""长轮询拉新消息。返回 (消息列表, 新游标);游标传回下次调用。"""
with httpx.Client(timeout=timeout) as c:
r = c.post(
f"{self.base_url}/ilink/bot/getupdates",
json={"get_updates_buf": cursor, "base_info": _base_info()},
headers=_headers(self.bot_token),
)
r.raise_for_status()
d = r.json()
msgs: list[InboundMessage] = []
for m in d.get("msgs", []) or []:
text = "".join(
(it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or []
)
msgs.append(InboundMessage(
from_user_id=m.get("from_user_id", ""),
context_token=m.get("context_token", ""),
text=text,
raw=m,
))
return msgs, d.get("get_updates_buf", cursor)
# —— 发(底层单条)——
def _send(
self, to_user_id: str, context_token: str, item: dict, *, state: int
) -> None:
body = {
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": _new_client_id(),
"message_type": MSG_TYPE_BOT,
"message_state": state,
"context_token": context_token,
"item_list": [item],
},
"base_info": _base_info(),
}
with httpx.Client(timeout=30.0) as c:
r = c.post(
f"{self.base_url}/ilink/bot/sendmessage",
json=body,
headers=_headers(self.bot_token),
)
# 成功为 HTTP 200 + 空 body {};非 200 抛错(空 body 不代表失败)
r.raise_for_status()
# —— 发文本(自动分块,长文不丢)——
def send_text(self, to_user_id: str, context_token: str, text: str) -> None:
text = text or ""
chunks = [text[i:i + CHUNK_CHARS] for i in range(0, len(text), CHUNK_CHARS)] or [""]
last = len(chunks) - 1
for i, chunk in enumerate(chunks):
self._send(
to_user_id, context_token,
{"type": ITEM_TEXT, "text_item": {"text": chunk}},
state=STATE_FINISH if i == last else STATE_GENERATING,
)
if i != last:
time.sleep(CHUNK_DELAY_S)
# —— 发文件(getuploadurl → AES-128-ECB → CDN → file_item)——
def _upload_file(self, to_user_id: str, data: bytes) -> dict[str, Any]:
rawsize = len(data)
rawmd5 = hashlib.md5(data).hexdigest()
aeskey = os.urandom(16)
filekey = os.urandom(16).hex()
ciphertext = _aes_ecb_pkcs7(data, aeskey)
filesize = len(ciphertext)
with httpx.Client(timeout=30.0) as c:
ru = c.post(
f"{self.base_url}/ilink/bot/getuploadurl",
json={
"filekey": filekey,
"media_type": UPLOAD_MEDIA_FILE,
"to_user_id": to_user_id,
"rawsize": rawsize,
"rawfilemd5": rawmd5,
"filesize": filesize,
"no_need_thumb": True,
"aeskey": aeskey.hex(),
"base_info": _base_info(),
},
headers=_headers(self.bot_token),
)
ru.raise_for_status()
uj = ru.json()
full = (uj.get("upload_full_url") or uj.get("uploadFullUrl")
or uj.get("full_url") or uj.get("url"))
param = (uj.get("upload_param") or uj.get("uploadParam") or uj.get("param"))
if full:
cdn_url = full
elif param:
cdn_url = (f"{CDN_BASE}/upload?encrypted_query_param={quote(param)}"
f"&filekey={quote(filekey)}")
else:
raise RuntimeError(f"getuploadurl 无 upload url/param: {uj}")
rc = c.post(cdn_url, content=ciphertext,
headers={"Content-Type": "application/octet-stream"})
download_param = rc.headers.get("x-encrypted-param")
if rc.status_code != 200 or not download_param:
raise RuntimeError(
f"CDN 上传失败 http={rc.status_code} "
f"err={rc.headers.get('x-error-message')}"
)
return {
"encrypt_query_param": download_param,
"aes_key": base64.b64encode(aeskey.hex().encode()).decode(),
"rawsize": rawsize,
}
def send_file(
self,
to_user_id: str,
context_token: str,
file_path: str | os.PathLike,
*,
file_name: Optional[str] = None,
) -> None:
data = _read_file_capped(file_path)
name = file_name or os.path.basename(str(file_path))
up = self._upload_file(to_user_id, data)
item = {
"type": ITEM_FILE,
"file_item": {
"media": {
"encrypt_query_param": up["encrypt_query_param"],
"aes_key": up["aes_key"],
"encrypt_type": 1,
},
"file_name": name,
"len": str(up["rawsize"]),
},
}
self._send(to_user_id, context_token, item, state=STATE_FINISH)
def _read_file_capped(file_path: str | os.PathLike) -> bytes:
size = os.path.getsize(file_path)
if size > MAX_FILE_BYTES:
raise ValueError(f"文件超过 {MAX_FILE_BYTES // (1024*1024)}MB 上限")
with open(file_path, "rb") as f:
return f.read()

144
core/wechat/inbound.py Normal file
View File

@ -0,0 +1,144 @@
"""入站长轮询管理器(DESIGN §8.7):收用户消息 → 跑 agent → 回复发回。
- 每个 active 绑定一条 `getupdates` 长轮询(ilink 同步, to_thread);收到消息:
`service.refresh_context_token` 刷新 24h 推送窗口; 调注入的 `handle_message`
(app.py 提供:解析/建该用户常驻微信task run `_run_agent_bg` 取回复);
用本轮新鲜 `context_token` 分块发回
- 每绑定 loop **串行**处理(再收):天然避免同用户并发 run 锁冲突;不同用户并发
- 管理器周期性对账 active 绑定:新增起 loop撤销/revoke loop
`handle_message` 注入解耦 app.py 内部(broker / run / _run_agent_bg);本模块只管协议循环
与回复提取(`extract_last_assistant_text` 纯函数可测)
"""
from __future__ import annotations
import asyncio
from typing import Any, Awaitable, Callable, Optional
from uuid import UUID
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Message
from core.wechat import service
from core.wechat.ilink import ILinkClient
from core.wechat.service import BindingSnapshot
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)
HandleMessage = Callable[[UUID, str], Awaitable[str]]
def _content_to_text(content: Any) -> str:
"""OpenAI 风格 content → 纯文本(str 直返;content blocks 拼 text 段)。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") in (None, "text"):
parts.append(b.get("text", ""))
return "".join(parts)
return ""
def extract_last_assistant_text(task_id: UUID, *, scan: int = 20) -> str:
"""取该 task 最后一条**有正文**的 assistant 消息文本(跳过纯 tool_calls 行)。"""
with session_scope() as s:
rows = s.execute(
select(Message.payload)
.where(Message.task_id == task_id)
.order_by(Message.idx.desc())
.limit(scan)
).all()
for (payload,) in rows:
if not isinstance(payload, dict) or payload.get("role") != "assistant":
continue
text = _content_to_text(payload.get("content"))
if text.strip():
return text
return ""
async def _poll_binding(
snap: BindingSnapshot, handle_message: HandleMessage, stop: asyncio.Event
) -> None:
"""单个绑定的长轮询循环。异常退避重试,直到 stop。"""
client = ILinkClient(snap.bot_token, snap.base_url)
cursor = ""
backoff = 2
while not stop.is_set():
try:
msgs, cursor = await asyncio.to_thread(client.get_updates, cursor)
backoff = 2
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} getupdates err: "
f"{type(e).__name__}: {e}; retry in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
continue
for m in msgs:
if stop.is_set():
break
if not m.text.strip():
continue
# ① 刷新该用户推送窗口(主动推靠它续命)
await asyncio.to_thread(
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
)
# ② 跑 agent 取回复
try:
reply = await handle_message(snap.user_id, m.text)
except Exception as e: # noqa: BLE001
reply = f"[出错] {type(e).__name__}: {e}"
# ③ 用本轮新鲜 token 分块回
if reply and reply.strip():
try:
await asyncio.to_thread(
client.send_text, m.from_user_id, m.context_token, reply
)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} reply send err: "
f"{type(e).__name__}: {e}")
async def run_inbound_manager(
handle_message: HandleMessage,
stop: asyncio.Event,
*,
reconcile_seconds: int = 60,
) -> None:
"""常驻管理器:周期对账 active 绑定,起/停 per-binding 长轮询循环。"""
loops: dict[UUID, asyncio.Task] = {}
try:
while not stop.is_set():
try:
active = await asyncio.to_thread(service.list_active_bindings)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] list bindings err: {type(e).__name__}: {e}")
active = []
active_ids = {s.user_id for s in active}
# 起新增
for snap in active:
t = loops.get(snap.user_id)
if t is None or t.done():
loops[snap.user_id] = asyncio.create_task(
_poll_binding(snap, handle_message, stop),
name=f"wechat-poll-{str(snap.user_id)[:8]}",
)
# 清撤销 / 已结束
for uid in list(loops):
if uid not in active_ids:
loops.pop(uid).cancel()
elif loops[uid].done():
loops.pop(uid)
await _wait_stop(stop, reconcile_seconds) # 等 stop 或到下次对账
finally:
for t in loops.values():
t.cancel()
async def _wait_stop(stop: asyncio.Event, timeout: float) -> None:
try:
await asyncio.wait_for(stop.wait(), timeout=timeout)
except asyncio.TimeoutError:
pass

194
core/wechat/service.py Normal file
View File

@ -0,0 +1,194 @@
"""微信渠道服务层(DESIGN §8.7):绑定 CRUD + 主动推送 + `send_to_user` 渠道抽象。
- 绑定行的 `bot_token` / `latest_context_token` `crypto` 加解密;快照(BindingSnapshot)
脱离 session含明文 token,** host 进程内用,绝不外泄/进沙箱**
- 主动推送 24h 窗口:`context_token` 仅在末次入站 ~24h 内可用;超期/未开口 推不出,
返回 reason 给调用方退邮件兜底(§8.5)
- `send_to_user` 是渠道抽象:scheduler / WechatPushTool 调它,不感知 ClawBot/企业微信;
企业微信(渠道 B)后续在此追加一路
阻塞 IO(DB + httpx),调用方放 to_thread / executor
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import WeChatBotBinding
from core.wechat import crypto
from core.wechat.ilink import DEFAULT_BASE, ILinkClient
CONTEXT_TOKEN_TTL = timedelta(hours=24)
def clawbot_enabled() -> bool:
"""ClawBot 渠道总开关(沿用「有开关才挂」范式,§3.4)。"""
return os.getenv("ZCBOT_WECHAT_BOT_ENABLED", "").strip().lower() in (
"1", "true", "yes", "on",
)
# ─────────────────────────── 绑定快照 / CRUD ───────────────────────────
@dataclass
class BindingSnapshot:
user_id: UUID
bot_token: str # 明文(已解密)
base_url: str
user_im_id: Optional[str]
context_token: Optional[str] # 明文(已解密)
context_token_at: Optional[datetime]
chat_task_id: Optional[UUID]
status: str
def _snap(row: WeChatBotBinding) -> BindingSnapshot:
return BindingSnapshot(
user_id=row.user_id,
bot_token=crypto.dec(row.bot_token) or "",
base_url=row.base_url or DEFAULT_BASE,
user_im_id=row.user_im_id,
context_token=crypto.dec(row.latest_context_token),
context_token_at=row.context_token_at,
chat_task_id=row.chat_task_id,
status=row.status,
)
def get_binding(user_id: UUID) -> Optional[BindingSnapshot]:
with session_scope() as s:
row = s.get(WeChatBotBinding, user_id)
return _snap(row) if row else None
def list_active_bindings() -> list[BindingSnapshot]:
"""入站长轮询管理器用:所有 active 绑定(含明文 bot_token)。"""
with session_scope() as s:
rows = (
s.execute(
select(WeChatBotBinding).where(WeChatBotBinding.status == "active")
)
.scalars()
.all()
)
return [_snap(r) for r in rows]
def upsert_clawbot_binding(
user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None
) -> None:
"""扫码 confirmed 后写/更新绑定。bot_token 加密入库。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(WeChatBotBinding, user_id)
if row is None:
row = WeChatBotBinding(user_id=user_id)
s.add(row)
row.bot_token = crypto.enc(bot_token)
row.base_url = base_url or DEFAULT_BASE
if bot_im_id:
row.bot_im_id = bot_im_id
row.status = "active"
row.updated_at = now
def refresh_context_token(user_id: UUID, user_im_id: str, context_token: str) -> None:
"""每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(WeChatBotBinding, user_id)
if row is None:
return
if user_im_id:
row.user_im_id = user_im_id
row.latest_context_token = crypto.enc(context_token)
row.context_token_at = now
row.updated_at = now
def set_chat_task(user_id: UUID, task_id: UUID) -> None:
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(WeChatBotBinding, user_id)
if row is not None:
row.chat_task_id = task_id
row.updated_at = now
def unbind(user_id: UUID) -> bool:
"""解绑(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(WeChatBotBinding, user_id)
if row is None:
return False
row.status = "revoked"
row.updated_at = now
return True
# ─────────────────────────── 推送 ───────────────────────────
@dataclass
class PushResult:
ok: bool
channel: str = "clawbot"
# sent | no_binding | never_opened | token_stale | error:<...>
reason: str = ""
def _token_fresh(snap: BindingSnapshot) -> bool:
if not snap.context_token or snap.context_token_at is None:
return False
at = snap.context_token_at
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - at) < CONTEXT_TOKEN_TTL
def push_clawbot(
user_id: UUID, text: str = "", file_path: Optional[str] = None
) -> PushResult:
"""主动推一条到用户个人微信。仅在 24h 窗口内可用,否则返回 reason 供兜底。"""
snap = get_binding(user_id)
if snap is None or snap.status != "active":
return PushResult(False, reason="no_binding")
if not snap.user_im_id or not snap.context_token:
return PushResult(False, reason="never_opened") # 冷启动:用户从未开口
if not _token_fresh(snap):
return PushResult(False, reason="token_stale") # 超 24h 未互动
client = ILinkClient(snap.bot_token, snap.base_url)
try:
if text:
client.send_text(snap.user_im_id, snap.context_token, text)
if file_path:
client.send_file(snap.user_im_id, snap.context_token, file_path)
except Exception as e: # noqa: BLE001 —— 调用方据 reason 决定兜底
return PushResult(False, reason=f"error:{type(e).__name__}")
return PushResult(True, reason="sent")
@dataclass
class DeliveryReport:
results: list[PushResult] = field(default_factory=list)
@property
def delivered(self) -> bool:
return any(r.ok for r in self.results)
def send_to_user(
user_id: UUID, text: str = "", file_path: Optional[str] = None
) -> DeliveryReport:
"""渠道抽象:按用户已绑渠道投递。当前仅 ClawBot;企业微信(渠道 B)后续追加。"""
report = DeliveryReport()
if clawbot_enabled():
report.results.append(push_clawbot(user_id, text, file_path))
# TODO 渠道 B:if wecom_configured(): report.results.append(push_wecom(user_id, text, file_path))
return report

View File

@ -0,0 +1,63 @@
"""wechat_bot_bindings 表(ClawBot 个人微信绑定,DESIGN §8.7 渠道 A).
Revision ID: 0012
Revises: 0011
Create Date: 2026-06-24
新增独立表 wechat_bot_bindings 不碰现有 schema(公测兼容)一行 = 一个用户绑定其
个人微信 ClawBotbot_token / latest_context_token 存密文(core/wechat/crypto.py)
入站长轮询管理器按 status='active' 拉绑定起 getupdates 循环;主动推送用 latest_context_token
(24h 内有效) DESIGN §8.7 / core/storage/models.py
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
revision: str = "0012"
down_revision: Union[str, None] = "0011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"wechat_bot_bindings",
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("bot_token", sa.Text(), nullable=False),
sa.Column("bot_im_id", sa.Text(), nullable=True),
sa.Column("user_im_id", sa.Text(), nullable=True),
sa.Column(
"base_url", sa.Text(), nullable=False,
server_default="https://ilinkai.weixin.qq.com",
),
sa.Column("latest_context_token", sa.Text(), nullable=True),
sa.Column("context_token_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"chat_task_id", PG_UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True,
),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
# 入站管理器扫 active 绑定起长轮询
op.create_index(
"ix_wechat_bot_bindings_active", "wechat_bot_bindings", ["status"],
)
def downgrade() -> None:
op.drop_index("ix_wechat_bot_bindings_active", table_name="wechat_bot_bindings")
op.drop_table("wechat_bot_bindings")

View File

@ -19,6 +19,10 @@ html2text>=2024.0
# 定时任务(§8.5 scheduled_jobs):cron 串 → next_run_at 计算,正确处理 dom/dow OR 语义 + 时区 # 定时任务(§8.5 scheduled_jobs):cron 串 → next_run_at 计算,正确处理 dom/dow OR 语义 + 时区
croniter>=2.0 croniter>=2.0
# 微信接入(§8.7 ClawBot):segno 渲绑定二维码;cryptography 做凭据列加密 + 文件 AES-128-ECB
segno>=1.6
cryptography>=42.0
# §7 B 阶段: Storage 落 PG # §7 B 阶段: Storage 落 PG
sqlalchemy>=2.0.0 sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0 psycopg[binary]>=3.1.0

150
scripts/probe_clawbot.py Normal file
View File

@ -0,0 +1,150 @@
"""一次性探测:微信 ClawBot 灰度是否覆盖某个微信号。
只做两件事(不碰 zcbot 主体不落库):
1. GET get_bot_qrcode 拿二维码 -> qr.png 并自动打开
2. 轮询 get_qrcode_status 等扫码确认 -> 报告 status
判读:
- 接口连不通 / 200 -> 本机到 ilinkai 网络不通,换网或在有网机器跑
- 出码成功手机扫得动确认 -> 该微信号在灰度内,ClawBot 可用
- 出码成功扫了报"不支持" -> 版本不够或未灰度到该号
ASCII-only 输出(Windows GBK 控制台)
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import webbrowser
import httpx
BASE = "https://ilinkai.weixin.qq.com"
QR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawbot_qr.png")
def _uin_header() -> str:
# X-WECHAT-UIN: base64(String(randomUint32()))
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers() -> dict:
return {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin_header(),
}
def _save_qr(img_content: str, qrcode_id: str) -> bool:
"""实测:qrcode_img_content 是微信深链(https://liteapp.weixin.qq.com/q/...),
需把该 URL **编码成二维码** 让微信扫,而非当图片下载
兜底:若哪天返回的是真图片字节(data-uri / base64 PNG)则直接存
"""
try:
if not img_content:
print(f"[hint] no img content; encode this id manually: {qrcode_id}")
return False
# 情况 A:真图片字节
if img_content.startswith("data:image"):
data = base64.b64decode(img_content.split(",", 1)[1])
with open(QR_PATH, "wb") as f:
f.write(data)
print(f"[ok] QR (image) saved -> {QR_PATH}")
return True
# 情况 B(实测):深链 / 任意字符串 -> 自己渲染成二维码
import segno
print(f"[info] encoding deep-link into QR: {img_content}")
segno.make(img_content, error="m").save(QR_PATH, scale=8, border=3)
print(f"[ok] QR (rendered from deep-link) saved -> {QR_PATH}")
return True
except Exception as e:
print(f"[warn] could not build QR: {type(e).__name__}: {e}")
print(f"[hint] deep-link to scan manually: {img_content}")
return False
def main() -> int:
print("[step1] GET get_bot_qrcode ...")
try:
with httpx.Client(timeout=20) as c:
r = c.get(
f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"},
headers=_headers(),
)
except Exception as e:
print(f"[FAIL] network error to {BASE}: {type(e).__name__}: {e}")
print("[judge] host cannot reach ilinkai.weixin.qq.com -> try another network.")
return 2
print(f"[http] status={r.status_code}")
body_preview = r.text[:600]
print(f"[body] {body_preview}")
if r.status_code != 200:
print("[judge] non-200 from get_bot_qrcode -> endpoint/params may be wrong or blocked.")
return 3
try:
data = r.json()
except Exception:
print("[FAIL] response not JSON; see body above.")
return 3
qrcode_id = data.get("qrcode") or data.get("qrcode_id") or ""
img = data.get("qrcode_img_content") or data.get("qrcode_img") or ""
if not qrcode_id:
print("[FAIL] no 'qrcode' field in response; field names differ -> inspect body above.")
return 3
if _save_qr(img, qrcode_id):
try:
webbrowser.open("file://" + QR_PATH.replace("\\", "/"))
except Exception:
pass
print("[action] QR opened. Scan it with your phone WeChat NOW.")
else:
print("[action] QR image unavailable; cannot open. See hint above.")
poll_secs = int(sys.argv[1]) if len(sys.argv) > 1 else 100
print(f"[step2] polling get_qrcode_status (up to ~{poll_secs}s; Ctrl-C to stop)...")
deadline = time.time() + poll_secs
last = ""
with httpx.Client(timeout=40) as c:
while time.time() < deadline:
try:
r = c.get(
f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qrcode_id},
headers=_headers(),
)
st = ""
try:
st = (r.json() or {}).get("status", "")
except Exception:
st = f"(non-json http {r.status_code})"
if st != last:
print(f"[poll] status={st!r}")
last = st
if st == "confirmed":
j = r.json()
tok = j.get("bot_token", "")
base_url = j.get("baseurl") or j.get("base_url") or ""
masked = (tok[:6] + "..." + tok[-4:]) if len(tok) > 12 else "(short)"
print("[SUCCESS] scan confirmed -> this WeChat account IS in the ClawBot rollout.")
print(f"[SUCCESS] bot_token={masked} baseurl={base_url}")
print("[note] token masked on purpose; it is a per-user credential.")
return 0
except Exception as e:
print(f"[poll] error: {type(e).__name__}: {e}")
time.sleep(2)
print("[timeout] no confirmation within window. Either not scanned in time, or")
print("[timeout] your WeChat lacks the ClawBot entry (version <8.0.70 or not gray-rolled).")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,181 @@
"""探测二:微信 ClawBot 的【对话】与【主动推送】能力(命门验证)。
流程(都在一次运行里,不落库):
1. 扫码绑定拿 bot_token(同探测一)
2. getupdates 长轮询,等你给微信 ClawBot联系人发一条消息
3. 收到后,依次测三种发送,逐一报 ret:
A. context_token 回复 -> 被动回复是否通
B. 25s ,同一个context_token 再发 -> 开口一次后能否延迟主动推
C. context_token 置空再发 -> 冷推( token)是否被拒
判读:
A = 双向对话成立
B = 用户开口一次后可后续推送(简报可走"先开口、后定时推"的弱化版)
C = 可冷推(几乎不可能,但要验)
B/C 都不通 = ClawBot 纯被动回复,定时主动推送这条路不成立
ASCII-only 输出bot_token 不打印
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawbot_qr.png")
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token: str | None = None) -> dict:
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin(),
}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def bind() -> tuple[str, str] | None:
print("[bind] GET get_bot_qrcode ...")
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] get_bot_qrcode http {r.status_code}: {r.text[:300]}")
return None
d = r.json()
qid = d.get("qrcode", "")
link = d.get("qrcode_img_content", "")
segno.make(link, error="m").save(QR_PATH, scale=8, border=3)
try:
import webbrowser
webbrowser.open("file://" + QR_PATH.replace("\\", "/"))
except Exception:
pass
print(f"[bind] QR opened -> {QR_PATH} SCAN IT NOW with phone WeChat.")
deadline = time.time() + 180
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
r = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers())
j = r.json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}")
last = st
if st == "confirmed":
print("[bind] confirmed.")
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
print("[bind] QR expired before scan.")
return None
except Exception as e:
print(f"[bind] poll err: {type(e).__name__}: {e}")
time.sleep(2)
print("[bind] timeout waiting for scan.")
return None
def _send(client: httpx.Client, token: str, to_user: str, text: str,
context_token: str) -> dict:
body = {
"msg": {
"to_user_id": to_user,
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{"type": 1, "text_item": {"text": text}}],
}
}
r = client.post(f"{BASE}/ilink/bot/sendmessage",
json=body, headers=_headers(token))
try:
return {"http": r.status_code, "json": r.json()}
except Exception:
return {"http": r.status_code, "text": r.text[:300]}
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[chat] now SEND a message (e.g. 'hi') to the WeChat ClawBot contact on your phone.")
print("[chat] waiting via getupdates (up to ~150s)...")
buf = ""
deadline = time.time() + 150
got = None
with httpx.Client(timeout=40) as c:
while time.time() < deadline and got is None:
try:
r = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token))
j = r.json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = ""
for it in m.get("item_list", []) or []:
txt += (it.get("text_item", {}) or {}).get("text", "")
print(f"[chat] <- from={m.get('from_user_id')} text={txt!r}")
got = m
break
except Exception as e:
print(f"[chat] getupdates err: {type(e).__name__}: {e}")
time.sleep(2)
if got is None:
print("[chat] no message received in window. Re-run and send promptly after scan.")
return 1
to_user = got.get("from_user_id", "")
ctx = got.get("context_token", "")
print(f"[chat] captured to_user={to_user} context_token_len={len(ctx)}")
with httpx.Client(timeout=30) as c:
print("\n[testA] reply WITH context_token ...")
ra = _send(c, token, to_user, "[zcbot 测试A] 收到你的消息,这是带 token 的回复。", ctx)
print(f"[testA] result={ra}")
print("\n[testB] wait 25s, then push again with the SAME context_token (delayed proactive)...")
time.sleep(25)
rb = _send(c, token, to_user, "[zcbot 测试B] 这是25秒后用同一token的延迟主动推送。", ctx)
print(f"[testB] result={rb}")
print("\n[testC] push with EMPTY context_token (cold push) ...")
rc = _send(c, token, to_user, "[zcbot 测试C] 这是空token的冷推送。", "")
print(f"[testC] result={rc}")
def ok(r):
j = r.get("json") or {}
return r.get("http") == 200 and j.get("ret", -1) == 0
print("\n========== VERDICT ==========")
print(f"A reply(with token) : {'OK' if ok(ra) else 'FAIL'}")
print(f"B delayed push(same token) : {'OK' if ok(rb) else 'FAIL'}")
print(f"C cold push(empty token) : {'OK' if ok(rc) else 'FAIL'}")
print("Interpretation:")
print(" - A only -> reply-only; scheduled PROACTIVE push NOT possible.")
print(" - A+B -> after user opens chat once, delayed push works (weak push OK).")
print(" - C -> true cold push works (unlikely).")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,158 @@
"""探测五(决定性):补上 client_id(每条唯一)+ base_info,重验两件事。
A. 流式多条:同一 context_token 连发 3 (client_id 各异,state 1/1/2,间隔300ms)
-> 三块都到 = 多条/长简报可行
B. finish 后复用:发完 FINISH,等30s,同一 context_token+ client_id 再发一条(state=2)
-> = context_token 24h 内可复用 -> "用户开口一次后可主动推" 成立(简报推送复活)
之前失败的最大嫌疑: client_id(后续块无法路由被丢)需要你发一条消息触发
ASCII-only,bot_token 不打印
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import uuid
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token=None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr():
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] http {r.status_code}"); return None
d = r.json()
uniq = os.path.join(QR_DIR, f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq}")
return d.get("qrcode", "")
def bind():
print("[bind] auto-refresh on expiry; scan whenever ready.")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
except Exception as e:
print(f"[bind] err {e}")
time.sleep(2)
return None
def send(c, token, to_user, text, ctx, state, tag):
cid = uuid.uuid4().hex
body = {
"msg": {
"to_user_id": to_user,
"client_id": cid,
"message_type": 2,
"message_state": state,
"context_token": ctx,
"item_list": [{"type": 1, "text_item": {"text": text}}],
},
"base_info": {"channel_version": CHANNEL_VER},
}
r = c.post(f"{BASE}/ilink/bot/sendmessage", json=body, headers=_headers(token))
try:
j = r.json()
except Exception:
j = r.text[:160]
print(f"[send {tag}] state={state} client_id={cid[:8]} -> http={r.status_code} body={j}")
def wait_msg(c, token):
deadline = time.time() + 150
buf = ""
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m
except Exception as e:
print(f"[recv] err {e}"); time.sleep(2)
return None
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n[A] SEND one message now (e.g. 'go') ...")
with httpx.Client(timeout=30) as c:
m = wait_msg(c, token)
if not m:
print("no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
print("[A] streaming 3 chunks WITH client_id (state 1,1,2, 300ms apart)...")
send(c, token, to_user, "[A1] client_id+流式第一段(state=1)", ctx, 1, "A1")
time.sleep(0.3)
send(c, token, to_user, "[A2] client_id+流式第二段(state=1)", ctx, 1, "A2")
time.sleep(0.3)
send(c, token, to_user, "[A3] client_id+末段(state=2 FINISH)", ctx, 2, "A3")
print("\n[B] wait 30s, then reuse SAME context_token + new client_id (state=2)...")
time.sleep(30)
send(c, token, to_user, "[B] finish后30秒,复用同token主动推(若到=24h可复用)", ctx, 2, "B")
print("\n========== CHECK YOUR PHONE ==========")
print("Report which arrived:")
print(" [A1]/[A2]/[A3] -> all three = multi-message/streaming OK (need client_id)")
print(" [B] -> arrived = token reusable after finish => PROACTIVE PUSH revives")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,223 @@
"""探测六:验证 ClawBot 能否发【文件附件】(照官方 @tencent-weixin/openclaw-weixin 协议复刻)。
流程(全诊断,每步打印):
绑定 -> 等你发一条消息( to_user + context_token) -> 造个小 txt ->
md5/随机aeskey(16B)/随机filekey(16B hex) -> AES-128-ECB+PKCS7 加密 ->
POST /ilink/bot/getuploadurl(打印完整返回,字段名不对可据此改) ->
POST 密文到 CDN header x-encrypted-param ->
sendmessage file_item(type=4) 引用 -> 看手机是否收到文件
字段依据(源码):MessageItemType.FILE=4 / UploadMediaType.FILE=3 / MessageState.FINISH=2,
aes_key = base64(aeskey.hex() ascii 字节)ASCII-only,bot_token 不打印
"""
from __future__ import annotations
import base64
import hashlib
import os
import random
import sys
import time
import uuid
from urllib.parse import quote
import httpx
import segno
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
BASE = "https://ilinkai.weixin.qq.com"
CDN_BASE_DEFAULT = "https://novac2c.cdn.weixin.qq.com/c2c"
QR_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token=None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr():
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] http {r.status_code}"); return None
d = r.json()
uniq = os.path.join(QR_DIR, f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq}")
return d.get("qrcode", "")
def bind():
print("[bind] auto-refresh on expiry; scan whenever ready.")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
except Exception as e:
print(f"[bind] err {e}")
time.sleep(2)
return None
def wait_msg(c, token):
deadline = time.time() + 150
buf = ""
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m
except Exception as e:
print(f"[recv] err {e}"); time.sleep(2)
return None
def aes_ecb_pkcs7(plain: bytes, key: bytes) -> bytes:
padder = padding.PKCS7(128).padder()
padded = padder.update(plain) + padder.finalize()
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
return enc.update(padded) + enc.finalize()
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n[file] SEND one message now (e.g. 'file') ...")
with httpx.Client(timeout=30) as c:
m = wait_msg(c, token)
if not m:
print("no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
# 1) 造测试文件
fpath = os.path.join(QR_DIR, "zcbot_filetest.txt")
with open(fpath, "w", encoding="utf-8") as f:
f.write("zcbot 文件发送测试\nClawBot file attachment probe\n" + "x" * 200)
data = open(fpath, "rb").read()
fname = "zcbot_filetest.txt"
rawsize = len(data)
rawmd5 = hashlib.md5(data).hexdigest()
aeskey = random.randbytes(16)
filekey = random.randbytes(16).hex()
cipher = aes_ecb_pkcs7(data, aeskey)
filesize = len(cipher)
print(f"[file] {fname} rawsize={rawsize} md5={rawmd5} filesize(enc)={filesize}")
# 2) getuploadurl
up_body = {
"filekey": filekey, "media_type": 3, "to_user_id": to_user,
"rawsize": rawsize, "rawfilemd5": rawmd5, "filesize": filesize,
"no_need_thumb": True, "aeskey": aeskey.hex(),
"base_info": {"channel_version": CHANNEL_VER},
}
ru = c.post(f"{BASE}/ilink/bot/getuploadurl", json=up_body, headers=_headers(token))
print(f"[getuploadurl] http={ru.status_code}")
try:
uj = ru.json()
except Exception:
print(f"[getuploadurl] non-json: {ru.text[:300]}"); return 3
print(f"[getuploadurl] resp={uj}")
# 3) 解析上传 URL(字段名不确定,多名兜底)
full = (uj.get("upload_full_url") or uj.get("uploadFullUrl")
or uj.get("full_url") or uj.get("url"))
param = (uj.get("upload_param") or uj.get("uploadParam") or uj.get("param"))
cdn_base = uj.get("cdn_base_url") or uj.get("cdnBaseUrl") or CDN_BASE_DEFAULT
if full:
cdn_url = full
elif param:
# 源码模板:?encrypted_query_param=<urlencode(uploadParam)>&filekey=<urlencode(filekey)>
cdn_url = (f"{cdn_base}/upload?encrypted_query_param={quote(param)}"
f"&filekey={quote(filekey)}")
else:
print("[FAIL] no upload url/param in resp; inspect resp above to fix field names.")
return 4
print(f"[upload] POST ciphertext -> {cdn_url[:120]}...")
# 4) 上传密文到 CDN
rc = c.post(cdn_url, content=cipher,
headers={"Content-Type": "application/octet-stream"})
download_param = rc.headers.get("x-encrypted-param")
print(f"[upload] http={rc.status_code} x-encrypted-param={download_param!r}")
if not download_param:
print(f"[upload] resp headers={dict(rc.headers)} body={rc.text[:200]}")
print("[FAIL] no x-encrypted-param returned; upload likely rejected.")
return 5
# 5) sendmessage 带 file_item
msg_body = {
"msg": {
"from_user_id": "", "to_user_id": to_user,
"client_id": f"openclaw-weixin-{uuid.uuid4().hex}",
"message_type": 2, "message_state": 2, "context_token": ctx,
"item_list": [{
"type": 4,
"file_item": {
"media": {
"encrypt_query_param": download_param,
"aes_key": base64.b64encode(aeskey.hex().encode()).decode(),
"encrypt_type": 1,
},
"file_name": fname,
"len": str(rawsize),
},
}],
},
"base_info": {"channel_version": CHANNEL_VER},
}
rs = c.post(f"{BASE}/ilink/bot/sendmessage", json=msg_body, headers=_headers(token))
try:
sj = rs.json()
except Exception:
sj = rs.text[:200]
print(f"[sendmessage file] http={rs.status_code} body={sj}")
print("\n========== CHECK YOUR PHONE ==========")
print(f"Did a file '{fname}' arrive in the WeChat ClawBot chat (openable)?")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,147 @@
"""探测四:验证 ClawBot 流式/多条回复(message_state 非 FINISH 是关键)。
上轮发现:message_state=2 = FINISH,"封口"本轮,故第二条被丢
本轮:同一 context_token 连发三段前两段 state=1(未结束),末段 state=2(FINISH),
看手机收到的形态:
- 三条独立气泡 AAA / BBB / CCC -> 支持多条独立消息
- 一条气泡里 AAABBBCCC(增长) -> 流式增量(delta),拼成一条
- 只剩 CCC -> 流式覆盖(cumulative,末值胜)
据此定长简报的发法需要你发一条消息触发bot_token 不打印ASCII-only
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token: str | None = None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr() -> str | None:
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] http {r.status_code}: {r.text[:200]}"); return None
d = r.json()
uniq = os.path.join(QR_DIR, f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq}")
return d.get("qrcode", "")
def bind() -> tuple[str, str] | None:
print("[bind] auto-refresh on expiry; scan whenever ready.")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
print("[bind] expired -> new QR");
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
continue
except Exception as e:
print(f"[bind] err {type(e).__name__}: {e}")
time.sleep(2)
return None
def send(c, token, to_user, text, ctx, state):
body = {"msg": {"to_user_id": to_user, "message_type": 2, "message_state": state,
"context_token": ctx,
"item_list": [{"type": 1, "text_item": {"text": text}}]}}
r = c.post(f"{BASE}/ilink/bot/sendmessage", json=body, headers=_headers(token))
try:
j = r.json()
except Exception:
j = r.text[:200]
print(f"[send] state={state} text={text!r} -> http={r.status_code} body={j}")
def wait_msg(c, token):
deadline = time.time() + 150
buf = ""
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m
except Exception as e:
print(f"[recv] err {type(e).__name__}: {e}"); time.sleep(2)
return None
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n[stream] SEND one message now (e.g. 'go') ...")
with httpx.Client(timeout=30) as c:
m = wait_msg(c, token)
if not m:
print("[stream] no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
print("[stream] sending 3 parts with same token (state 1,1,2)...")
send(c, token, to_user, "AAA-第一段(state=1)", ctx, 1)
time.sleep(1)
send(c, token, to_user, "BBB-第二段(state=1)", ctx, 1)
time.sleep(1)
send(c, token, to_user, "CCC-第三段(state=2,FINISH)", ctx, 2)
print("\n========== CHECK YOUR PHONE ==========")
print("Which form did you get?")
print(" (a) three separate bubbles: AAA / BBB / CCC -> multi-message OK")
print(" (b) one bubble growing: AAABBBCCC -> streaming delta-append")
print(" (c) one bubble only: CCC -> streaming cumulative(last wins)")
print(" (d) only AAA / nothing else -> still single")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,168 @@
"""探测三:钉死 ClawBot 的 context_token 语义(决定拉取式简报 + 长回复可行性)。
要回答两个问题:
T1 多发:一条用户消息收到后,同一个新鲜 token连发两条回复
-> 第二条到不到 = 能否分段/多条回复(长简报关键)
T2 延迟:第二条用户消息收到后,先不回, 25s,再用那条没用过的token 回一次
-> 到不到 = token 是否限时(能否把回复推迟一会儿)
需要你先后发两条消息微信 ClawBot(比如先发 1,再发 2)
结果以手机实收为准(接口返空 body 不可信)bot_token 不打印ASCII-only
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawbot_qr.png")
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token: str | None = None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr() -> str | None:
"""拉一张新二维码、弹窗,返回 qrcode id;失败返回 None。"""
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] get_bot_qrcode http {r.status_code}: {r.text[:200]}")
return None
d = r.json()
qid = d.get("qrcode", "")
uniq = os.path.join(os.path.dirname(QR_PATH), f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
try:
import webbrowser
webbrowser.open("file://" + uniq.replace("\\", "/"))
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq} (older windows are stale, ignore them)")
return qid
def bind() -> tuple[str, str] | None:
"""过期自动换新码,直到扫成功或总超时(5min)。消除扫码时间竞争。"""
print("[bind] GET get_bot_qrcode ... (auto-refresh on expiry; scan whenever ready)")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
print("[bind] QR expired -> generating a new one ...")
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
continue
except Exception as e:
print(f"[bind] err {type(e).__name__}: {e}")
time.sleep(2)
print("[bind] overall timeout (5min)."); return None
def send(c, token, to_user, text, ctx):
body = {"msg": {"to_user_id": to_user, "message_type": 2, "message_state": 2,
"context_token": ctx,
"item_list": [{"type": 1, "text_item": {"text": text}}]}}
r = c.post(f"{BASE}/ilink/bot/sendmessage", json=body, headers=_headers(token))
try:
return {"http": r.status_code, "json": r.json()}
except Exception:
return {"http": r.status_code, "text": r.text[:200]}
def wait_msg(c, token, buf):
"""阻塞等下一条用户消息,返回 (msg, new_buf)。"""
deadline = time.time() + 150
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m, buf
except Exception as e:
print(f"[recv] err {type(e).__name__}: {e}"); time.sleep(2)
return None, buf
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n")
with httpx.Client(timeout=40) as c:
# ---- T1: 同一 token 连发两条 ----
print("[T1] SEND your 1st message now (e.g. '1') ...")
m, buf = wait_msg(c, token, "")
if not m:
print("[T1] no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
r1a = send(c, token, to_user, "[T1-a] 同token第一条(立即)", ctx)
r1b = send(c, token, to_user, "[T1-b] 同token第二条(紧接)", ctx)
print(f"[T1] sent two with same token. http: a={r1a.get('http')} b={r1b.get('http')}")
# ---- T2: 收到后不回,延迟 25s 再用未用过的 token 回一次 ----
print("\n[T2] SEND your 2nd message now (e.g. '2') ...")
m2, buf = wait_msg(c, token, buf)
if not m2:
print("[T2] no msg; skip.");
else:
to_user2, ctx2 = m2.get("from_user_id", ""), m2.get("context_token", "")
print("[T2] received; NOT replying; waiting 25s...")
time.sleep(25)
r2 = send(c, token, to_user2, "[T2] 延迟25秒,未用过的token回复", ctx2)
print(f"[T2] sent after delay. http={r2.get('http')}")
print("\n========== CHECK YOUR PHONE ==========")
print("Report which of these arrived in the WeChat ClawBot chat:")
print(" [T1-a] 同token第一条(立即)")
print(" [T1-b] 同token第二条(紧接) <- if arrives: multi-message per turn OK")
print(" [T2] 延迟25秒,未用过的token回复 <- if arrives: token is time-windowed, deferred reply OK")
return 0
if __name__ == "__main__":
sys.exit(main())

84
tools/wechat_bot.py Normal file
View File

@ -0,0 +1,84 @@
"""微信主动推送工具(DESIGN §8.7 渠道抽象)。host-side,按 user_id 注入。
agent (交互 / 定时)run 里调:把一段文本 + 可选产物文件**主动推**到用户已绑的微信
(当前 ClawBot 个人微信;企业微信渠道后续在 `service.send_to_user` 内追加)仅当渠道开关
在才挂(沿用有开关才注册§3.4)
ClawBot 推送有 **24h 窗口**约束:用户超期未在微信里跟机器人说过话 / 从未开口 推不出,
工具返回明确 reason,agent 可改走 `send_email`密钥/凭据只在 host 进程,绝不进沙箱
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from uuid import UUID
from core.wechat import service
from .base import Tool
def wechat_push_available() -> bool:
"""任一微信渠道可用(当前 = ClawBot 开关在;后续 or 企业微信配齐)。"""
return service.clawbot_enabled()
_REASON_HINT = {
"no_binding": "用户还没绑定微信(让其在 zcbot 网页扫码绑定)",
"never_opened": "用户绑了但从未在微信里跟机器人说过话(主动推需先开口一次)",
"token_stale": "距用户上次在微信互动已超 24h,主动推送窗口已过(让其发条消息即可恢复)",
}
class WechatPushTool(Tool):
name = "wechat_push"
description = (
"Proactively push a short text message (and optionally one result file, e.g. a .docx/.pdf "
"report) to the user's bound WeChat. Use when the user asks to send something to their "
"WeChat, or when a scheduled task should deliver its output there. NOTE: WeChat push only "
"works if the user has messaged the bot within the last ~24h; if it returns a window/binding "
"error, fall back to send_email. The file path is relative to the working directory."
)
parameters = {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Message text to push (plain text)."},
"file": {
"type": "string",
"description": "Optional path (relative to working dir) of one file to attach, e.g. 'report.docx'.",
},
},
"required": ["text"],
}
def __init__(self, user_id: UUID, base_dir=None, user_root=None) -> None:
super().__init__(base_dir=base_dir, user_root=user_root)
self.user_id = user_id
def execute(self, text: str = "", file: Optional[str] = None) -> str:
text = (text or "").strip()
if not text and not file:
return "[Error] text 不能为空"
fpath: Optional[str] = None
if file and file.strip():
p = self._resolve(file.strip()).resolve()
if self.user_root is not None:
try:
p.relative_to(self.user_root.resolve())
except ValueError:
return f"[Error] 文件路径越界(必须在工作目录内): {file}"
if not p.is_file():
return f"[Error] 文件不存在: {file}"
fpath = str(p)
report = service.send_to_user(self.user_id, text, fpath)
if report.delivered:
n = "(含 1 个文件)" if fpath else ""
return f"[ok] 已推送到微信 {n}".strip()
if not report.results:
return "[Error] 没有可用的微信渠道(未开启 / 未配置)"
# 取首个失败原因给 agent 决定是否改走 send_email
r = report.results[0]
hint = _REASON_HINT.get(r.reason, r.reason)
return f"[Error] 微信推送未送达({r.channel}: {r.reason})。{hint}。可改用 send_email。"

View File

@ -813,6 +813,7 @@ def create_app() -> FastAPI:
None, lambda: deliver_notify( None, lambda: deliver_notify(
snap["notify"], job_name=snap["name"], snap["notify"], job_name=snap["name"],
working_dir=fs_dir, tz=snap["tz"], working_dir=fs_dir, tz=snap["tz"],
user_id=snap["user_id"],
) )
) )
except Exception as e: except Exception as e:
@ -848,6 +849,81 @@ def create_app() -> FastAPI:
if scheduler_enabled: if scheduler_enabled:
print(f"[scheduler] enabled (tick={sched_tick}s)") print(f"[scheduler] enabled (tick={sched_tick}s)")
# ── 微信(ClawBot)入站长轮询管理器(§8.7)── 仅当 ZCBOT_WECHAT_BOT_ENABLED 在。
# 每个 active 绑定一条 getupdates 长轮询;收到消息 → 跑用户常驻「微信」task → 回复发回。
from core.wechat.service import clawbot_enabled
wechat_stop = asyncio.Event()
wechat_task = None
async def _run_wechat_message(uid: UUID, text: str) -> str:
"""微信入站一条消息:解析/建用户常驻「微信」task → 抢 run 锁 → _run_agent_bg → 取回复。"""
from core.agent_builder import resolve_workspace, working_dir_from_name
from core.storage.utils import ensure_local_task_row
from core.wechat import service as _wx
from core.wechat.inbound import extract_last_assistant_text
snap = await asyncio.to_thread(_wx.get_binding, uid)
if snap is None:
return ""
profile, model_id = _resolve_model_profile("")
ws = resolve_workspace(None, _cfg)
tid = snap.chat_task_id
need_create = tid is None
if not need_create:
with session_scope() as s:
exists = s.execute(
select(Task.task_id).where(Task.task_id == tid, Task.deleted_at.is_(None))
).first()
if exists is None:
need_create = True
if need_create:
tid = uuid4()
fs_dir = working_dir_from_name(ws, uid, f"wechat-{str(uid)[:8]}")
fs_dir.mkdir(parents=True, exist_ok=True)
ensure_local_task_row(
task_id=tid, name="微信对话", working_dir=to_db_path(fs_dir),
skill="", user_id=uid, model=model_id, model_profile=profile,
description="(微信 ClawBot 对话)",
)
await asyncio.to_thread(_wx.set_chat_task, uid, tid)
# 抢 run 锁:正忙 → 提示稍候(同用户串行,inbound loop 本就串行)
with session_scope() as s:
row = s.execute(
select(Task.run_status).where(Task.task_id == tid).with_for_update()
).first()
if row is None:
return "[出错] 对话 task 不存在"
if row.run_status in ("running", "cancelling"):
return "上一条还在处理中,请稍候再发。"
s.execute(update(Task).where(Task.task_id == tid).values(
run_status="running", run_error=None))
broker.start(tid)
runner = asyncio.create_task(asyncio.to_thread(
_run_agent_bg, tid, uid, text, "", "", False,
))
app.state.inflight[runner] = tid
runner.add_done_callback(lambda t: app.state.inflight.pop(t, None))
await runner
with session_scope() as s:
st = s.execute(
select(Task.run_status, Task.run_error).where(Task.task_id == tid)
).first()
if st is not None and st.run_status == "error":
return f"[出错] {st.run_error}"
reply = await asyncio.to_thread(extract_last_assistant_text, tid)
return reply or "(本轮无文本回复)"
if clawbot_enabled():
from core.wechat.inbound import run_inbound_manager
wechat_task = asyncio.create_task(
run_inbound_manager(_run_wechat_message, wechat_stop),
name="wechat-inbound",
)
print("[wechat] ClawBot inbound manager enabled")
# Sandbox pool(§7.5):仅当 ZCBOT_SANDBOX_BACKEND=docker 时启用。 # Sandbox pool(§7.5):仅当 ZCBOT_SANDBOX_BACKEND=docker 时启用。
# 启动钩子:① init_pool(创建 docker network + pool 实例)② shutdown_all 清 # 启动钩子:① init_pool(创建 docker network + pool 实例)② shutdown_all 清
# 前驱孤儿(上次进程留下的 zcbot-sandbox-* 容器,内存 _last_active 为空, # 前驱孤儿(上次进程留下的 zcbot-sandbox-* 容器,内存 _last_active 为空,
@ -945,6 +1021,13 @@ def create_app() -> FastAPI:
await scheduler_task await scheduler_task
except (asyncio.CancelledError, Exception): except (asyncio.CancelledError, Exception):
pass pass
if wechat_task is not None:
wechat_stop.set()
wechat_task.cancel()
try:
await wechat_task
except (asyncio.CancelledError, Exception):
pass
if sandbox_reaper_task is not None: if sandbox_reaper_task is not None:
sandbox_reaper_task.cancel() sandbox_reaper_task.cancel()
try: try:
@ -1005,6 +1088,67 @@ def create_app() -> FastAPI:
""" """
return {"user_id": str(user_id), "role": get_user_role(user_id) or "user"} return {"user_id": str(user_id), "role": get_user_role(user_id) or "user"}
# ───────────── 微信接入(ClawBot,§8.7)─────────────
@app.post("/v1/wechat/bind/qrcode", tags=["wechat"])
async def wechat_bind_qrcode(user_id: UUID = Depends(require_user)):
"""起一张 ClawBot 绑定二维码(渲成 PNG data-uri)。前端展示,用户手机微信扫;
二维码 TTL ~1min,前端轮询到 expired 后重调本端点换码"""
import base64 as _b64
import io as _io
import segno
from core.wechat import ilink
qr = await asyncio.to_thread(ilink.get_bot_qrcode)
buf = _io.BytesIO()
segno.make(qr.deeplink, error="m").save(buf, kind="png", scale=6, border=3)
data_uri = "data:image/png;base64," + _b64.b64encode(buf.getvalue()).decode()
return {"qrcode_id": qr.qrcode_id, "qr_png": data_uri}
@app.get("/v1/wechat/bind/status", tags=["wechat"])
async def wechat_bind_status(qrcode_id: str, user_id: UUID = Depends(require_user)):
"""轮询扫码状态(服务端长轮询,hold 数十秒)。confirmed → 写绑定。
返回 {status: wait|confirmed|expired};expired 时前端重起二维码"""
from core.wechat import ilink
from core.wechat import service as _wx
res = await asyncio.to_thread(ilink.poll_qrcode_status, qrcode_id)
if res.status == "confirmed" and res.bot_token:
await asyncio.to_thread(
_wx.upsert_clawbot_binding, user_id, res.bot_token,
res.base_url or ilink.DEFAULT_BASE,
)
return {"status": res.status}
@app.get("/v1/wechat/bind", tags=["wechat"])
def wechat_bind_get(user_id: UUID = Depends(require_user)):
"""当前用户的微信绑定状态(不泄露 token)。"""
from core.wechat import service as _wx
snap = _wx.get_binding(user_id)
if snap is None or snap.status != "active":
return {"bound": False}
return {
"bound": True,
"user_im_id": snap.user_im_id,
"can_push": bool(_wx._token_fresh(snap)), # 24h 窗口内可主动推
"last_active": _iso(snap.context_token_at),
}
@app.delete("/v1/wechat/bind", status_code=204, tags=["wechat"])
def wechat_unbind(user_id: UUID = Depends(require_user)):
from core.wechat import service as _wx
_wx.unbind(user_id)
return
@app.post("/v1/wechat/test", tags=["wechat"])
async def wechat_test(user_id: UUID = Depends(require_user)):
"""自检:给已绑用户推一条测试消息(需用户近 24h 在微信开口过)。"""
from core.wechat import service as _wx
res = await asyncio.to_thread(
_wx.push_clawbot, user_id, "zcbot 测试消息:绑定成功,这条来自你的 zcbot。"
)
return {"ok": res.ok, "reason": res.reason}
@app.get("/v1/models", tags=["misc"]) @app.get("/v1/models", tags=["misc"])
def list_models(user_id: UUID = Depends(require_user)): def list_models(user_id: UUID = Depends(require_user)):
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。 """列出所有可用 LLM 模型(扫 config/models/*.yaml)。

152
web/static/wechat_bind.html Normal file
View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>绑定微信 · zcbot</title>
<style>
:root { --fg:#1f2328; --muted:#656d76; --line:#d0d7de; --accent:#a01e1e; --bg:#f6f8fa; }
* { box-sizing: border-box; }
body { font: 15px/1.6 system-ui, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--fg); margin: 0; background: var(--bg); }
.wrap { max-width: 520px; margin: 40px auto; padding: 0 16px; }
.card { background: #fff; border: 1px solid var(--line); border-radius: 12px;
padding: 24px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
button { font: inherit; cursor: pointer; border: 1px solid var(--line);
background: #fff; border-radius: 8px; padding: 8px 16px; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button:disabled { opacity: .5; cursor: default; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 14px; }
#qrbox { text-align: center; margin: 18px 0; }
#qrbox img { width: 240px; height: 240px; border: 1px solid var(--line); border-radius: 8px; }
.hint { color: var(--muted); font-size: 13px; margin-top: 8px; }
.status { padding: 10px 12px; border-radius: 8px; font-size: 14px; margin-top: 14px; }
.ok { background: #e6f4ea; color: #1a7f37; }
.err { background: #ffebe9; color: #cf222e; }
.wait { background: #fff8c5; color: #7d4e00; }
code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.steps { font-size: 13px; color: var(--muted); margin: 14px 0 0; padding-left: 18px; }
</style>
</head>
<body>
<div class="wrap"><div class="card">
<h1>绑定微信(ClawBot)</h1>
<p class="sub">绑定后可在个人微信「微信 ClawBot」里直接和 zcbot 对话;近 24h 内有互动时,定时简报 / 结果也能主动推给你。</p>
<div id="state" class="status wait">加载中…</div>
<div class="row">
<button id="btn-bind" class="primary">绑定 / 重新绑定</button>
<button id="btn-test" disabled>发送测试消息</button>
<button id="btn-unbind" disabled>解绑</button>
</div>
<div id="qrbox" hidden>
<img id="qrimg" alt="微信扫码二维码">
<div class="hint" id="qrhint">用手机微信「扫一扫」上面的二维码,在手机上确认授权。</div>
</div>
<ol class="steps">
<li>需个人微信 8.0.70+ 且已灰度到「ClawBot」插件(设置→插件)。</li>
<li>绑定成功后,先在微信里给「微信 ClawBot」发一句话,主动推送才会开启(24h 窗口)。</li>
</ol>
</div></div>
<script>
const LS_TOKEN = "zcbot.token";
const token = () => localStorage.getItem(LS_TOKEN) || "";
const $ = (id) => document.getElementById(id);
let polling = false;
async function api(path, opts = {}) {
opts.headers = Object.assign({ "Authorization": "Bearer " + token() }, opts.headers || {});
const r = await fetch(path, opts);
if (r.status === 204) return null;
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.detail || ("HTTP " + r.status));
return body;
}
function setState(cls, msg) {
const el = $("state");
el.className = "status " + cls;
el.textContent = msg;
}
async function refresh() {
if (!token()) { setState("err", "未登录:请先在 zcbot 主页登录,再打开本页。"); return; }
try {
const b = await api("/v1/wechat/bind");
if (b.bound) {
const push = b.can_push ? "可主动推送" : "需在微信里发条消息以开启主动推送";
setState("ok", "已绑定" + (b.user_im_id ? "" : "(待首次开口)") + " · " + push);
$("btn-test").disabled = false;
$("btn-unbind").disabled = false;
} else {
setState("wait", "尚未绑定。点「绑定」生成二维码,用手机微信扫。");
$("btn-test").disabled = true;
$("btn-unbind").disabled = true;
}
} catch (e) { setState("err", "查询失败:" + e.message); }
}
async function bindFlow() {
if (polling) return;
polling = true;
$("btn-bind").disabled = true;
try {
while (polling) {
const q = await api("/v1/wechat/bind/qrcode", { method: "POST" });
$("qrimg").src = q.qr_png;
$("qrbox").hidden = false;
setState("wait", "等待扫码…(二维码约 1 分钟过期,会自动换新)");
// 轮询该二维码状态
let expired = false;
while (polling && !expired) {
let s;
try { s = await api("/v1/wechat/bind/status?qrcode_id=" + encodeURIComponent(q.qrcode_id)); }
catch (e) { await sleep(2000); continue; }
if (s.status === "confirmed") {
$("qrbox").hidden = true;
polling = false;
setState("ok", "绑定成功!现在去微信「微信 ClawBot」发一句话试试。");
await refresh();
return;
}
if (s.status === "expired") { expired = true; break; }
await sleep(1000);
}
}
} catch (e) {
setState("err", "绑定出错:" + e.message);
} finally {
polling = false;
$("btn-bind").disabled = false;
}
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
$("btn-bind").onclick = bindFlow;
$("btn-unbind").onclick = async () => {
polling = false;
if (!confirm("确定解绑微信?")) return;
try { await api("/v1/wechat/bind", { method: "DELETE" }); $("qrbox").hidden = true; await refresh(); }
catch (e) { setState("err", "解绑失败:" + e.message); }
};
$("btn-test").onclick = async () => {
$("btn-test").disabled = true;
try {
const r = await api("/v1/wechat/test", { method: "POST" });
if (r.ok) setState("ok", "测试消息已发送,去微信查收。");
else setState("err", "推送未送达(" + r.reason + ")。多半是超过 24h 没在微信互动:发条消息再试。");
} catch (e) { setState("err", "测试失败:" + e.message); }
finally { $("btn-test").disabled = false; }
};
refresh();
</script>
</body>
</html>