diff --git a/DESIGN.md b/DESIGN.md index 3c250ed..6fa13e8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -716,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——相对企业微信回调的根本简化。 - **出站主动推送**(scheduler 简报 / 任务结果 / `WechatPushTool`):用库里该用户 `latest_context_token`,**距上次入站 <~24h** 则直接 `sendmessage`(文本 + docx/pdf 文件直推);**超期 / 从未开口** → 推不出,退邮件兜底(§8.5)或挂起待用户下次开口刷新 token。即"用户开口过、且近 24h 活跃 → 可主动推"。 - **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。 diff --git a/PROGRESS.md b/PROGRESS.md index bb4876e..d95f296 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-24(微信入站收图片/文件:CDN 下载+AES 解密落盘,复用 [用户上传的参考图] 约定喂 look_at_image + bump 0.25.0) +最后更新:2026-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1) --- @@ -21,6 +21,11 @@ ## 已完成关键能力 +### 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`(剥路径防穿越)。 diff --git a/core/__init__.py b/core/__init__.py index 2606f84..69bb460 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.25.0" +__version__ = "0.25.1" diff --git a/web/static/dev.html b/web/static/dev.html index caa9ab8..1428bd6 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -824,6 +824,8 @@ } #chat-form .row { display: flex; gap: 8px; } #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); } /* ───── files ───── */ diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 65c7684..7e46646 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -261,6 +261,7 @@ export async function selectTask(tid) { ]); state.taskMeta = meta; renderChatMeta(); + applyChannelComposerLock(meta); if (meta.run_status === "running" || meta.run_status === "cancelling") { ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`); } 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 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端 // 不与主对话 run 互斥,各跑各的 LLM)。 @@ -1093,6 +1114,11 @@ function takePastedRels() { async function sendMessage(overrideText) { if (!state.taskId) return; if (isCurrentTaskStreaming()) return; + // 微信渠道 task 只读:web 发的消息推不到微信(单向),挡在入口避免不一致(见 applyChannelComposerLock) + if (state.taskMeta && state.taskMeta.channel === "wechat") { + $("chat-hint").textContent = "微信对话请在微信里进行 — web 端为只读镜像"; + return; + } const fromInput = typeof overrideText !== "string"; let content = (fromInput ? $("chat-input").value : overrideText).trim(); // 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,