Compare commits

...

2 Commits

Author SHA1 Message Date
caoqianming b5cfce72b5 feat(wechat): web 端微信 task 只读镜像,锚定微信为单一交互入口 + bump 0.25.1
web 端打开 channel=wechat 的常驻 task 原能正常发消息,但 web→微信单向
不同步(web 发的走通用端点 → _run_agent_bg,不经过 inbound loop 里
send_text 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。

不做双向打通:回微信需 context_token、只能从入站拿且 24h 过期,双向同步
会被该窗口拖成"有时同步"(不可预测)+ 两入口并发写歧义。改为 web 端只读
镜像,交互权威单一锚定微信;主动推走 wechat_push / 定时简报。

- chat.js: applyChannelComposerLock(selectTask 后调)对 wechat task 置
  chat-input readOnly + 改 placeholder 引导去微信 + 禁润色;sendMessage
  入口加 channel 守卫(Enter 兜底)
- dev.html: .readonly-locked 置灰样式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:30:22 +08:00
caoqianming 529d7f1046 feat(wechat): 入站收图片/文件,CDN 下载+AES 解密落盘 + bump 0.25.0
get_updates 原只抽 text_item,图片/文件 item 被丢成空 text,inbound
又因空文本 continue → 用户发的图/文件静默丢弃、零落库(DB 实证)。

- ilink: InboundAttachment + 解析 image_item/file_item + download_media
  (CDN /c2c/download GET 密文 → AES-128-ECB 解,发送侧加密的逆);key 双
  编码兜底(base64(raw16)/base64(hex32)),图片按 magic bytes 补扩展名
- inbound: handle_message 契约加附件参,文本/附件都空才跳过,下载失败
  只丢该附件不拖垮整条
- app.py: 附件落盘 <wd>/inbound/,图片拼 [用户上传的参考图](走
  look_at_image)、文件拼 [用户上传的文件](走 Read/Shell),复用 web 端
  粘贴图约定,不碰模型链路

crypto roundtrip + 双编码 key decode 已单测;端到端(GET/POST、真实
image_item 结构)待用户重发一张图实测。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:15:52 +08:00
8 changed files with 215 additions and 16 deletions

View File

@ -706,6 +706,7 @@ create index on usage_events (model_profile, created_at);
**协议要点(自实现客户端,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>` **协议要点(自实现客户端,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),实现要**过期自动换码**。 - **取码/绑定**:`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/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}`
- **收图片/文件(2026-06-24)**:`item_list` 项除 `text_item` 外还有 `image_item`(type=2,带 `media{encrypt_query_param, aes_key, encrypt_type}` + 优先 `aeskey` 32-hex)、`file_item`(type=4,带 `media` + `file_name` + `len`);**下载是文件发送(下条)的逆操作**——`GET {cdn_base}/download?encrypted_query_param=<media.encrypt_query_param>` 取密文 → **AES-128-ECB+PKCS7 解密**(key 优先图片 `aeskey`,否则 `media.aes_key` 两种编码兜底:base64(raw16) / base64(hex32))。落盘 `<wd>/inbound/`,图片拼 `[用户上传的参考图]`(走 `look_at_image`)、文件拼 `[用户上传的文件]`(走 Read/Shell)注入 user 消息,**复用 web 端粘贴图约定,不碰模型链路**。⚠️ 下载 GET/POST 与 aes_key 取支待真机端到端校(crypto 单测已过)。
- **发**:`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 判成败,以实投为准)。 - **发**:`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 凭据(扫码下发)。 - **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 简报可原生直推为可打开附件**,无须退下载链接。 - **文件发送(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 简报可原生直推为可打开附件**,无须退下载链接。
@ -715,6 +716,7 @@ create index on usage_events (model_profile, created_at);
- **入站长轮询管理器**(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——相对企业微信回调的根本简化。 - **入站长轮询管理器**(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 活跃 → 可主动推"。 - **出站主动推送**(scheduler 简报 / 任务结果 / `WechatPushTool`):用库里该用户 `latest_context_token`,**距上次入站 <~24h** 则直接 `sendmessage`(文本 + docx/pdf 文件直推);**超期 / 从未开口** → 推不出,退邮件兜底(§8.5)或挂起待用户下次开口刷新 token。即"用户开口过、且近 24h 活跃 → 可主动推"。
- **scale**:N 个 active binding = N 条长轮询;公测期 N 小可接受;放大时视 1:1/1:N 实测结果改为单循环轮询多 token。 - **scale**:N 个 active binding = N 条长轮询;公测期 N 小可接受;放大时视 1:1/1:N 实测结果改为单循环轮询多 token。
- **web↔微信同步不对称 → web 端只读镜像(2026-06-24 取舍)**:这条 persistent「微信」task 是 web 与微信共享的同一条 DB 消息流,但写入方向不对称——**微信→web 同步**(入站经 `_poll_binding` 落库,web 打开即见),**web→微信不同步**(web 端发消息走通用 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知)。**不做双向打通**:回微信需 `context_token`、只能从入站拿且 24h 过期,双向同步会被该窗口拖成"有时同步"(不可预测)+ 两入口并发写同一上下文歧义。改为 web 端对 channel=wechat 的 task **只读镜像**(`applyChannelComposerLock` 置 readOnly + 引导去微信),交互权威单一锚定微信;主控台想主动往微信推 → 走 `WechatPushTool`/定时简报(出站语义,非对话)。
**接入面(复用现有范式)**: **接入面(复用现有范式)**:
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。 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。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3) 最后更新:2026-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1)
--- ---
@ -21,6 +21,18 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-24 / 微信 task 在 web 端只读镜像(bump 0.25.1)
- 问题:web 端打开 channel=wechat 的常驻 task 能正常发消息,但 web→微信**单向不同步**(web 发消息走 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。
- 取舍:不做"双向打通"(受微信 24h `context_token` 窗口约束 → 只能"有时同步",不可预测 + 两入口并发写歧义),改为 web 端**只读镜像**(单一交互权威锚定微信;想主动推走 `wechat_push`/定时简报)。
- `web/static/js/chat.js`:`applyChannelComposerLock(meta)`(selectTask 后调)对 wechat task 置 `chat-input` readOnly + 改 placeholder「请在微信里对话」+ 禁润色;`sendMessage` 入口加 channel 守卫(Enter 兜底)。`dev.html` 加 `.readonly-locked` 置灰样式。
### 2026-06-24 / 微信入站收图片/文件(bump 0.25.0)
- 缺口:`ILinkClient.get_updates` 只抽 `text_item`,图片/文件 item 被丢成空 text → `inbound._poll_binding` 又因空文本 `continue`,用户发的图/文件**静默丢弃、零落库**(DB 实证:caoqianming@foxmail.com 的微信 task 里发的图无任何记录)。
- `core/wechat/ilink.py`:新 `InboundAttachment`(kind/media/file_name/aeskey_hex/data);`get_updates` 解析 `image_item`(type=2)/`file_item`(type=4);新 `download_media()` = CDN `/c2c/download?encrypted_query_param=...` GET 密文 → `_aes_ecb_unpkcs7`(AES-128-ECB 解,发送侧 `_aes_ecb_pkcs7` 的逆);key 两种编码兜底 `_decode_media_aes_key`(base64(raw16) / base64(hex32),后者同发送侧);图片无名按 magic bytes 补扩展名 `_guess_image_ext` + `attachment_basename`(剥路径防穿越)。
- `core/wechat/inbound.py`:`HandleMessage` 契约加第三参 attachments;`_poll_binding` 先下载解密回填 `att.data`,文本/附件**都空才跳过**(单附件下载失败不拖垮整条)。
- `web/app.py:_run_wechat_message`:附件落盘 `<wd>/inbound/<ts>-<i>-<name>`,图片拼 `[用户上传的参考图] <rel>`(agent 自调 `look_at_image` 看图)、文件拼 `[用户上传的文件] <rel>`(agent 用 Read/Shell),**复用 web 端粘贴图同一约定**,不碰模型链路。
- 协议下载分支(GET vs POST、aes_key 取哪支)有真机实测风险:crypto roundtrip + 双编码 key decode 已单测通过;端到端待用户重发一张图验证(原图 cursor 已过)。
### 2026-06-24 / 微信绑定表重构:两表合一 channel_bindings(判别列+JSONB,bump 0.24.3) ### 2026-06-24 / 微信绑定表重构:两表合一 channel_bindings(判别列+JSONB,bump 0.24.3)
- 起因:ClawBot(0012 `wechat_bot_bindings`,8 列)+ 企微(0014 `wecom_bindings`,1 列)各一表。从架构角度复盘:渠道绑定本质="用户在某渠道的一份配置",各渠道字段形态不同 → 最优是**判别列 + JSONB 多态**(与本库 `usage_events` kind+units / `scheduled_jobs.notify` 同范式),加渠道(飞书/TG…)零 migration。分表不扛增长、与库内范式不一致;单宽表(NULL 列并列)最差。 - 起因:ClawBot(0012 `wechat_bot_bindings`,8 列)+ 企微(0014 `wecom_bindings`,1 列)各一表。从架构角度复盘:渠道绑定本质="用户在某渠道的一份配置",各渠道字段形态不同 → 最优是**判别列 + JSONB 多态**(与本库 `usage_events` kind+units / `scheduled_jobs.notify` 同范式),加渠道(飞书/TG…)零 migration。分表不扛增长、与库内范式不一致;单宽表(NULL 列并列)最差。

View File

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

View File

@ -19,7 +19,7 @@ import hashlib
import os import os
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import quote from urllib.parse import quote
@ -80,6 +80,46 @@ def _aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes:
return enc.update(padded) + enc.finalize() return enc.update(padded) + enc.finalize()
def _aes_ecb_unpkcs7(ciphertext: bytes, key: bytes) -> bytes:
"""收图/收文件的解密:AES-128-ECB 解 + 去 PKCS7(发送侧 `_aes_ecb_pkcs7` 的逆)。"""
dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor()
padded = dec.update(ciphertext) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def _decode_media_aes_key(raw: str) -> bytes:
"""媒体 `media.aes_key` → 16 字节 AES key。两种实测编码兜住:
- `base64(raw 16 bytes)`(图片常见) 解码得 16 字节直用;
- `base64(hex 字符串)`(文件/语音/视频,发送侧 `_upload_file` 也用这种) 解码得
32 ASCII hex 字符, `fromhex` 16 字节
"""
dec = base64.b64decode(raw)
if len(dec) == 16:
return dec
if len(dec) == 32:
try:
return bytes.fromhex(dec.decode("ascii"))
except (ValueError, UnicodeDecodeError):
return dec[:16]
return dec[:16]
def _guess_image_ext(data: bytes) -> str:
"""按 magic bytes 猜图片扩展名(微信入站图片无原文件名)。认不出回退 .jpg。"""
if data[:3] == b"\xff\xd8\xff":
return ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:6] in (b"GIF87a", b"GIF89a"):
return ".gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[:2] == b"BM":
return ".bmp"
return ".jpg"
# ─────────────────────────── 绑定(无 token)─────────────────────────── # ─────────────────────────── 绑定(无 token)───────────────────────────
@dataclass @dataclass
@ -130,12 +170,30 @@ def poll_qrcode_status(
# ─────────────────────────── 收发(带 token)─────────────────────────── # ─────────────────────────── 收发(带 token)───────────────────────────
@dataclass
class InboundAttachment:
"""入站附件(图片 / 文件)的 CDN 引用 + 下载后填充的明文字节。
协议结构(getupdates 返回的 item_list ,实测 + 逆向 photon-hq/wechat-ilink-client):
- 图片 `image_item`(type=2):`media{encrypt_query_param, aes_key, encrypt_type}`,
另带优先 `aeskey`(32 hex);文件名缺失,下载后按 magic bytes 补扩展名
- 文件 `file_item`(type=4):`media{...}` + `file_name` + `len`(明文大小)
"""
kind: str # "image" | "file"
media: dict[str, Any] # {encrypt_query_param, aes_key, encrypt_type}
file_name: str = "" # 文件原名(图片无名,落盘时按 magic bytes 生成)
aeskey_hex: str = "" # 图片优先 key:image_item.aeskey(32 hex chars)
size: int = 0 # 明文大小(file_item.len / image mid_size),仅参考
data: Optional[bytes] = None # 下载 + 解密后的明文,由调用方(inbound)回填
@dataclass @dataclass
class InboundMessage: class InboundMessage:
from_user_id: str # xxx@im.wechat from_user_id: str # xxx@im.wechat
context_token: str # 回复 / 24h 内主动推须带回 context_token: str # 回复 / 24h 内主动推须带回
text: str text: str
raw: dict[str, Any] raw: dict[str, Any]
attachments: list[InboundAttachment] = field(default_factory=list)
class ILinkClient: class ILinkClient:
@ -160,18 +218,62 @@ class ILinkClient:
d = r.json() d = r.json()
msgs: list[InboundMessage] = [] msgs: list[InboundMessage] = []
for m in d.get("msgs", []) or []: for m in d.get("msgs", []) or []:
text = "".join( text_parts: list[str] = []
(it.get("text_item", {}) or {}).get("text", "") attachments: list[InboundAttachment] = []
for it in m.get("item_list", []) or [] for it in m.get("item_list", []) or []:
) if it.get("text_item"):
text_parts.append((it["text_item"] or {}).get("text", ""))
img = it.get("image_item")
if img:
attachments.append(InboundAttachment(
kind="image",
media=img.get("media") or {},
aeskey_hex=(img.get("aeskey") or ""),
size=int(img.get("mid_size") or 0),
))
fil = it.get("file_item")
if fil:
attachments.append(InboundAttachment(
kind="file",
media=fil.get("media") or {},
file_name=(fil.get("file_name") or "file"),
size=int(fil.get("len") or 0),
))
msgs.append(InboundMessage( msgs.append(InboundMessage(
from_user_id=m.get("from_user_id", ""), from_user_id=m.get("from_user_id", ""),
context_token=m.get("context_token", ""), context_token=m.get("context_token", ""),
text=text, text="".join(text_parts),
raw=m, raw=m,
attachments=attachments,
)) ))
return msgs, d.get("get_updates_buf", cursor) return msgs, d.get("get_updates_buf", cursor)
# —— 收附件(CDN 下载 → AES-128-ECB 解密 → 明文 bytes)——
def download_media(self, att: InboundAttachment, *, timeout: float = 60.0) -> bytes:
"""下载并解密一个入站附件,返回明文 bytes(发送侧上传链路的逆操作)。
URL:`{CDN_BASE}/download?encrypted_query_param=<media.encrypt_query_param>`
Key 优先级:图片 `image_item.aeskey`(32 hex)> `media.aes_key`(两种编码,
`_decode_media_aes_key`)
"""
media = att.media or {}
qp = media.get("encrypt_query_param") or media.get("encrypted_query_param") or ""
if not qp:
raise RuntimeError(f"附件无 encrypt_query_param: kind={att.kind} media={media}")
url = f"{CDN_BASE}/download?encrypted_query_param={quote(qp)}"
with httpx.Client(timeout=timeout) as c:
# 下载语义按逆向文档是 GET;CDN 若只认 POST 则回退一次(下载幂等,无副作用)
r = c.get(url)
if r.status_code == 405 or (400 <= r.status_code < 500 and not r.content):
r = c.post(url, content=b"")
r.raise_for_status()
ciphertext = r.content
if att.aeskey_hex and len(att.aeskey_hex) == 32:
key = bytes.fromhex(att.aeskey_hex)
else:
key = _decode_media_aes_key(media.get("aes_key") or "")
return _aes_ecb_unpkcs7(ciphertext, key)
# —— 发(底层单条)—— # —— 发(底层单条)——
def _send( def _send(
self, to_user_id: str, context_token: str, item: dict, *, state: int self, to_user_id: str, context_token: str, item: dict, *, state: int
@ -289,6 +391,18 @@ class ILinkClient:
self._send(to_user_id, context_token, item, state=STATE_FINISH) self._send(to_user_id, context_token, item, state=STATE_FINISH)
def attachment_basename(att: InboundAttachment) -> str:
"""入站附件的安全落盘文件名(不含目录):剥掉路径分隔防穿越;图片按 magic bytes 补扩展名。
返回的是 basename,调用方负责加前缀(时间戳 / 随机)防重名并拼到 inbound 目录下
"""
if att.kind == "image":
ext = _guess_image_ext(att.data or b"")
return f"image{ext}"
name = os.path.basename((att.file_name or "file").replace("\\", "/")).strip()
return name or "file"
def _read_file_capped(file_path: str | os.PathLike) -> bytes: def _read_file_capped(file_path: str | os.PathLike) -> bytes:
size = os.path.getsize(file_path) size = os.path.getsize(file_path)
if size > MAX_FILE_BYTES: if size > MAX_FILE_BYTES:

View File

@ -21,11 +21,12 @@ from sqlalchemy import select
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import Message from core.storage.models import Message
from core.wechat import service from core.wechat import service
from core.wechat.ilink import ILinkClient from core.wechat.ilink import ILinkClient, InboundAttachment
from core.wechat.service import BindingSnapshot from core.wechat.service import BindingSnapshot
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空) # app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)。
HandleMessage = Callable[[UUID, str], Awaitable[str]] # 第三参 attachments:已下载解密(att.data 已回填)的入站附件,app.py 负责落盘 + 拼提示行。
HandleMessage = Callable[[UUID, str, list[InboundAttachment]], Awaitable[str]]
def _content_to_text(content: Any) -> str: def _content_to_text(content: Any) -> str:
@ -79,15 +80,25 @@ async def _poll_binding(
for m in msgs: for m in msgs:
if stop.is_set(): if stop.is_set():
break break
if not m.text.strip(): # 下载入站附件(图片/文件):CDN 取密文 → AES 解密 → 回填 att.data
atts: list[InboundAttachment] = []
for att in m.attachments:
try:
att.data = await asyncio.to_thread(client.download_media, att)
atts.append(att)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} download "
f"{att.kind} err: {type(e).__name__}: {e}")
# 文本和附件都没有(纯文本为空 / 附件全下载失败)→ 跳过整条
if not m.text.strip() and not atts:
continue continue
# ① 刷新该用户推送窗口(主动推靠它续命) # ① 刷新该用户推送窗口(主动推靠它续命)
await asyncio.to_thread( await asyncio.to_thread(
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
) )
# ② 跑 agent 取回复 # ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行)
try: try:
reply = await handle_message(snap.user_id, m.text) reply = await handle_message(snap.user_id, m.text, atts)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
reply = f"[出错] {type(e).__name__}: {e}" reply = f"[出错] {type(e).__name__}: {e}"
# ③ 用本轮新鲜 token 分块回 # ③ 用本轮新鲜 token 分块回

View File

@ -856,11 +856,17 @@ def create_app() -> FastAPI:
wechat_stop = asyncio.Event() wechat_stop = asyncio.Event()
wechat_task = None wechat_task = None
async def _run_wechat_message(uid: UUID, text: str) -> str: async def _run_wechat_message(uid: UUID, text: str, attachments=None) -> str:
"""微信入站一条消息:解析/建用户常驻「微信」task → 抢 run 锁 → _run_agent_bg → 取回复。""" """微信入站一条消息:解析/建用户常驻「微信」task → 落盘附件 → 抢 run 锁 → _run_agent_bg → 取回复。
attachments:已下载解密的入站附件(core.wechat.ilink.InboundAttachment,att.data 已回填)
图片落盘后拼 `[用户上传的参考图] <rel>` (agent 见之自调 look_at_image),文件拼
`[用户上传的文件] <rel>` (agent Read/Shell 处理)
"""
from core.agent_builder import resolve_workspace, working_dir_from_name from core.agent_builder import resolve_workspace, working_dir_from_name
from core.storage.utils import ensure_local_task_row from core.storage.utils import ensure_local_task_row
from core.wechat import service as _wx from core.wechat import service as _wx
from core.wechat.ilink import attachment_basename
from core.wechat.inbound import extract_last_assistant_text from core.wechat.inbound import extract_last_assistant_text
snap = await asyncio.to_thread(_wx.get_binding, uid) snap = await asyncio.to_thread(_wx.get_binding, uid)
@ -888,6 +894,32 @@ def create_app() -> FastAPI:
) )
await asyncio.to_thread(_wx.set_chat_task, uid, tid) await asyncio.to_thread(_wx.set_chat_task, uid, tid)
# 落盘入站附件到 <wd>/inbound/,拼 [用户上传的...] 行进 text(复用 web 端粘贴图约定)
if attachments:
from datetime import datetime
from pathlib import Path
with session_scope() as s:
wd_db = s.execute(
select(Task.working_dir).where(Task.task_id == tid)
).scalar_one()
inbound_dir = from_db_path(wd_db) / "inbound"
inbound_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
lines: list[str] = []
for i, att in enumerate(attachments):
if not att.data:
continue
base = attachment_basename(att)
name = f"{ts}-{i}-{base}"
(inbound_dir / name).write_bytes(att.data)
rel = f"inbound/{name}"
tag = "[用户上传的参考图]" if att.kind == "image" else "[用户上传的文件]"
lines.append(f"{tag} {rel}")
if lines:
extra = "\n".join(lines)
text = f"{text}\n\n{extra}" if text.strip() else extra
# 抢 run 锁:正忙 → 提示稍候(同用户串行,inbound loop 本就串行) # 抢 run 锁:正忙 → 提示稍候(同用户串行,inbound loop 本就串行)
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(

View File

@ -824,6 +824,8 @@
} }
#chat-form .row { display: flex; gap: 8px; } #chat-form .row { display: flex; gap: 8px; }
#chat-form textarea { flex: 1; } #chat-form textarea { flex: 1; }
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
#chat-form .hint { font-size: 11px; color: var(--muted); } #chat-form .hint { font-size: 11px; color: var(--muted); }
/* ───── files ───── */ /* ───── files ───── */

View File

@ -261,6 +261,7 @@ export async function selectTask(tid) {
]); ]);
state.taskMeta = meta; state.taskMeta = meta;
renderChatMeta(); renderChatMeta();
applyChannelComposerLock(meta);
if (meta.run_status === "running" || meta.run_status === "cancelling") { if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`); ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
} else { } else {
@ -972,6 +973,26 @@ async function deletePastedFile(rel, wrap) {
} }
} }
// 微信渠道 task 在 web 端只读:这条对话的唯一交互入口锚定在微信本身 —— agent 回复必须
// 带 context_token 才能发回微信,而 token 只能从用户微信入站消息拿(24h 过期),zcbot 在
// 协议层就没有对微信无条件说话的能力。故 web→微信单向不同步(web 发的微信侧看不到);
// 与其做受 24h 窗口拖累的"有时同步"双向打通,不如让 web 端做干净的只读镜像(单一交互
// 权威 + 可预测),想主动往微信推走 wechat_push / 定时简报。微信→web 仍同步(同一条 task)。
function applyChannelComposerLock(meta) {
const input = $("chat-input");
if (!input) return;
const isWechat = !!meta && meta.channel === "wechat";
input.readOnly = isWechat;
input.classList.toggle("readonly-locked", isWechat);
input.placeholder = isWechat
? "微信对话请在微信里进行 — web 端为只读镜像,可查看历史"
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)";
if (isWechat) {
const opt = $("chat-optimize");
if (opt) opt.disabled = true;
}
}
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText') // 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端 // 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
// 不与主对话 run 互斥,各跑各的 LLM)。 // 不与主对话 run 互斥,各跑各的 LLM)。
@ -1093,6 +1114,11 @@ function takePastedRels() {
async function sendMessage(overrideText) { async function sendMessage(overrideText) {
if (!state.taskId) return; if (!state.taskId) return;
if (isCurrentTaskStreaming()) return; if (isCurrentTaskStreaming()) return;
// 微信渠道 task 只读:web 发的消息推不到微信(单向),挡在入口避免不一致(见 applyChannelComposerLock)
if (state.taskMeta && state.taskMeta.channel === "wechat") {
$("chat-hint").textContent = "微信对话请在微信里进行 — web 端为只读镜像";
return;
}
const fromInput = typeof overrideText !== "string"; const fromInput = typeof overrideText !== "string";
let content = (fromInput ? $("chat-input").value : overrideText).trim(); let content = (fromInput ? $("chat-input").value : overrideText).trim();
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」, // 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,