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:
parent
336db63a01
commit
528b974d9f
|
|
@ -52,3 +52,8 @@ col.ps1
|
|||
|
||||
# brief skill 临时样例输出 (可由 skill 重新生成, 不入库)
|
||||
.brief_out/
|
||||
|
||||
# ClawBot 接入探测临时产物 (二维码图 / 测试文件, 探测时重新生成, 不入库;
|
||||
# 探测脚本 scripts/probe_clawbot*.py 保留作参考与复测)
|
||||
scripts/clawbot_qr*.png
|
||||
scripts/zcbot_filetest.txt
|
||||
|
|
|
|||
77
DESIGN.md
77
DESIGN.md
|
|
@ -656,6 +656,83 @@ create index on usage_events (model_profile, created_at);
|
|||
|
||||
**取舍**:重构对三 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)
|
||||
|
|
|
|||
11
PROGRESS.md
11
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `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)
|
||||
|
||||
- 背景:线上 `简报` 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
7
RUN.md
|
|
@ -55,9 +55,14 @@
|
|||
# ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)
|
||||
# ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏)
|
||||
# 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=...`。
|
||||
- **依赖**:`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)。
|
||||
- **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(见故障兜底)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.21.0"
|
||||
__version__ = "0.22.0"
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from tools.schedule import (
|
|||
ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool,
|
||||
)
|
||||
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.bocha_client import BochaConfig
|
||||
|
|
@ -568,6 +569,12 @@ def build_agent(
|
|||
se = SendEmailTool(base_dir=tool_base, user_root=ur_path)
|
||||
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:
|
||||
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
|
||||
tools[rp.name] = rp
|
||||
|
|
|
|||
|
|
@ -182,27 +182,8 @@ def _newest_artifact(working_dir: Path) -> Optional[Path]:
|
|||
return best
|
||||
|
||||
|
||||
def deliver_notify(
|
||||
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
|
||||
def _notify_email(to, job_name: str, when: str, artifact: Optional[Path]) -> None:
|
||||
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:
|
||||
subject = f"[定时任务] {job_name} · {when}"
|
||||
body = f"定时任务「{job_name}」已于 {when} 执行,产物见附件:{artifact.name}。"
|
||||
|
|
@ -213,6 +194,51 @@ def deliver_notify(
|
|||
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)─────────────
|
||||
#
|
||||
# tools/schedule.py(对话)与 web/app.py 的 /v1/schedules(前端只读+停用/删除)都调
|
||||
|
|
|
|||
|
|
@ -225,3 +225,44 @@ class ScheduledJob(Base):
|
|||
)
|
||||
|
||||
|
||||
class WeChatBotBinding(Base):
|
||||
"""ClawBot 个人微信绑定(0012,DESIGN §8.7 渠道 A)。
|
||||
|
||||
一行 = 一个 zcbot 用户绑定其个人微信「微信 ClawBot」。PK=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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
"""微信接入(DESIGN §8.7)。
|
||||
|
||||
渠道 A = ClawBot 个人微信 iLink Bot API(`ilink.py`,协议已真机实测,见
|
||||
`scripts/probe_clawbot*.py`);渠道 B = 企业微信自建应用(后续 `wecom.py`)。
|
||||
本包只放协议客户端等纯逻辑,与 DB / agent 编排解耦。
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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(公测兼容)。一行 = 一个用户绑定其
|
||||
个人微信 ClawBot。bot_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")
|
||||
|
|
@ -19,6 +19,10 @@ html2text>=2024.0
|
|||
# 定时任务(§8.5 scheduled_jobs):cron 串 → next_run_at 计算,正确处理 dom/dow OR 语义 + 时区
|
||||
croniter>=2.0
|
||||
|
||||
# 微信接入(§8.7 ClawBot):segno 渲绑定二维码;cryptography 做凭据列加密 + 文件 AES-128-ECB
|
||||
segno>=1.6
|
||||
cryptography>=42.0
|
||||
|
||||
# §7 B 阶段: Storage 落 PG
|
||||
sqlalchemy>=2.0.0
|
||||
psycopg[binary]>=3.1.0
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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。"
|
||||
144
web/app.py
144
web/app.py
|
|
@ -813,6 +813,7 @@ def create_app() -> FastAPI:
|
|||
None, lambda: deliver_notify(
|
||||
snap["notify"], job_name=snap["name"],
|
||||
working_dir=fs_dir, tz=snap["tz"],
|
||||
user_id=snap["user_id"],
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -848,6 +849,81 @@ def create_app() -> FastAPI:
|
|||
if scheduler_enabled:
|
||||
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 时启用。
|
||||
# 启动钩子:① init_pool(创建 docker network + pool 实例)② shutdown_all 清
|
||||
# 前驱孤儿(上次进程留下的 zcbot-sandbox-* 容器,内存 _last_active 为空,
|
||||
|
|
@ -945,6 +1021,13 @@ def create_app() -> FastAPI:
|
|||
await scheduler_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
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:
|
||||
sandbox_reaper_task.cancel()
|
||||
try:
|
||||
|
|
@ -1005,6 +1088,67 @@ def create_app() -> FastAPI:
|
|||
"""
|
||||
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"])
|
||||
def list_models(user_id: UUID = Depends(require_user)):
|
||||
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue