From 95857ba68768e5b9bbb898c57aae37a40cac98b2 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 24 Jun 2026 09:47:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat):=20=E7=BB=91=E5=AE=9A=20UI=20?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E4=B8=BB=20SPA(=E5=B7=A6=E6=A0=8F=20rail?= =?UTF-8?q?=E3=80=8C=E5=BE=AE=E4=BF=A1=E3=80=8D=E6=8C=89=E9=92=AE=20+=20?= =?UTF-8?q?=E6=89=AB=E7=A0=81=20modal)+=20bump=200.22.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上版绑定页是独立 /static/wechat_bind.html、主界面没入口、用户找不到。集成: rail 加「微信」按钮(hd-wechat)→ 扫码绑定 modal(wechat-modal),复用 api() 调已有 5 端点(起码/轮询/查/解绑/自检),仿 crons.js 范式;二维码过期自动换码。 独立页 wechat_bind.html 保留作嵌入/兜底入口。 文件:web/static/js/wechat.js(新)、dev.html(rail 按钮 + modal + CSS)、 main.js(import 触发顶层绑定 + Esc 关闭);RUN/PROGRESS 同步去掉"未并入 SPA"。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 10 +++- RUN.md | 2 +- core/__init__.py | 2 +- web/static/dev.html | 45 +++++++++++++++++ web/static/js/main.js | 2 + web/static/js/wechat.js | 109 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 web/static/js/wechat.js diff --git a/PROGRESS.md b/PROGRESS.md index c5713eb..cbf886f 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(微信接入第一期 ClawBot 后端 + 自包含绑定页;双渠道设计 §8.7 + bump 0.22.0) +最后更新:2026-06-24(微信绑定 UI 并入主 SPA:左栏 rail「微信」按钮 + 扫码 modal + bump 0.22.2) --- @@ -21,6 +21,12 @@ ## 已完成关键能力 +### 2026-06-24 / 微信绑定 UI 并入主 SPA(bump 0.22.2) + +- 上一版绑定页是独立 `/static/wechat_bind.html`,主界面没入口、用户找不到。 +- 集成:左栏 rail 加「微信」按钮(`hd-wechat`)→ 扫码绑定 modal(`wechat-modal`),复用 `api()` 调已有 5 端点(起码/轮询/查/解绑/自检),仿 `crons.js` modal 范式;过期自动换码、绑定成功提示去微信开口。文件:`web/static/js/wechat.js`(新)、`web/static/dev.html`(rail 按钮 + modal + CSS)、`web/static/js/main.js`(import 触发绑定 + Esc 关闭)。 +- 独立页 `web/static/wechat_bind.html` 保留作嵌入/兜底入口(同套端点)。 + ### 2026-06-24 / 修复顶栏 token 计量栏回复后不刷新(bump 0.22.1) - 现象:提问→助手答完后,对话顶栏的「总 token · 缓存命中 · 花费」计量栏停在发问前旧值,要切到别的 task 再切回才更新。 @@ -34,7 +40,7 @@ - **关键设计决策**:入站对话→每用户一条 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」实现。 +- **待办(部署后联调)**:migration `0012` 上库;起 web 进程端到端验(扫码绑定→对话→主动推→定时简报推);**渠道 B 企业微信**(无条件推送,补 ClawBot 24h 窗口短板)按 §8.7「渠道 B」实现。SPA 集成已落(见下条)。 ### 2026-06-23 / 平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf(bump 0.21.0) diff --git a/RUN.md b/RUN.md index fd96c75..1adfdbb 100644 --- a/RUN.md +++ b/RUN.md @@ -62,7 +62,7 @@ ``` > 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`、`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。 +- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env` 设 `ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。 - **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(见故障兜底)。 diff --git a/core/__init__.py b/core/__init__.py index d73cbce..96a8bf8 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.22.1" +__version__ = "0.22.2" diff --git a/web/static/dev.html b/web/static/dev.html index f8b45c1..f791e65 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -293,6 +293,18 @@ } /* 定时任务 modal(只读 + 停用/删除,DESIGN §8.5)— 复用 .sk-item/.sk-badge/.sk-empty */ + #wechat-modal { z-index: 112; } + #wechat-modal .card { width: 440px; max-width: 94vw; } + #wechat-modal h3 { display: flex; align-items: center; gap: 8px; } + #wechat-modal h3 .spacer { flex: 1; } + #wx-body { padding: 16px; overflow: auto; } + #wx-body .wx-status { padding: 10px 12px; border-radius: 8px; font-size: 14px; margin-bottom: 14px; background: var(--code-bg, #f6f8fa); } + #wx-body .wx-status.ok { background: #e6f4ea; color: #1a7f37; } + #wx-body .wx-status.err { background: #ffebe9; color: #cf222e; } + #wx-body .wx-status.wait { background: #fff8c5; color: #7d4e00; } + #wx-body .wx-acts { display: flex; gap: 8px; flex-wrap: wrap; } + #wx-qrbox { text-align: center; margin-top: 16px; } + #wx-qrbox img { width: 220px; height: 220px; border: 1px solid var(--line, #d0d7de); border-radius: 8px; } #crons-modal { z-index: 112; } #crons-modal .card { width: 880px; max-width: 94vw; height: 78vh; max-height: 78vh; @@ -1283,6 +1295,35 @@ + + + diff --git a/web/static/js/main.js b/web/static/js/main.js index 7ad588c..e134293 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -9,6 +9,7 @@ import { closeChpwModal } from "./auth.js"; import { closeSkillsModal } from "./skills.js"; import { closeMemoryModal } from "./memory.js"; import { closeCronsModal } from "./crons.js"; +import { closeWechatModal } from "./wechat.js"; import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeSrcPicker, loadFiles } from "./files.js"; import { loadFolderSuggestions } from "./newtask.js"; @@ -89,6 +90,7 @@ document.addEventListener("keydown", (e) => { if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; } if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; } if ($("crons-modal").classList.contains("show")) { closeCronsModal(); return; } + if ($("wechat-modal").classList.contains("show")) { closeWechatModal(); return; } if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } diff --git a/web/static/js/wechat.js b/web/static/js/wechat.js new file mode 100644 index 0000000..596b493 --- /dev/null +++ b/web/static/js/wechat.js @@ -0,0 +1,109 @@ +// 微信绑定 modal(ClawBot 扫码,DESIGN §8.7):扫码绑定 + 自检发送 + 解绑。 +// 左侧 rail「微信」按钮触发。后端:POST /v1/wechat/bind/qrcode、GET /v1/wechat/bind/status、 +// GET /v1/wechat/bind、DELETE /v1/wechat/bind、POST /v1/wechat/test。 +import { $ } from "./dom.js"; +import { api } from "./api.js"; + +let _polling = false; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function setState(cls, msg) { + const el = $("wx-state"); + el.className = "wx-status " + cls; + el.textContent = msg; +} + +async function refresh() { + try { + const b = await api("GET", "/v1/wechat/bind"); + if (b.bound) { + const push = b.can_push ? "可主动推送" : "需在微信里发条消息以开启主动推送"; + setState("ok", "已绑定" + (b.user_im_id ? "" : "(待首次开口)") + " · " + push); + $("wx-test").disabled = false; + $("wx-unbind").disabled = false; + } else { + setState("wait", "尚未绑定。点「绑定」生成二维码,用手机微信扫。"); + $("wx-test").disabled = true; + $("wx-unbind").disabled = true; + } + } catch (e) { + setState("err", "查询失败: " + e.message + + "(确认已上 migration 0012 + 开 ZCBOT_WECHAT_BOT_ENABLED)"); + $("wx-test").disabled = true; + $("wx-unbind").disabled = true; + } +} + +function openWechatModal() { + $("wechat-modal").classList.add("show"); + $("wx-qrbox").hidden = true; + refresh(); +} +export function closeWechatModal() { + _polling = false; + $("wechat-modal").classList.remove("show"); +} + +async function bindFlow() { + if (_polling) return; + _polling = true; + $("wx-bind").disabled = true; + try { + while (_polling) { // 二维码过期自动换新 + const q = await api("POST", "/v1/wechat/bind/qrcode"); + $("wx-qrimg").src = q.qr_png; + $("wx-qrbox").hidden = false; + setState("wait", "等待扫码…(二维码约 1 分钟过期,会自动换新)"); + let expired = false; + while (_polling && !expired) { + let s; + try { + s = await api("GET", "/v1/wechat/bind/status?qrcode_id=" + + encodeURIComponent(q.qrcode_id)); + } catch (e) { await sleep(2000); continue; } + if (s.status === "confirmed") { + _polling = false; + $("wx-qrbox").hidden = true; + 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; + $("wx-bind").disabled = false; + } +} + +// ───── 顶层绑定 ───── +$("hd-wechat").onclick = openWechatModal; +$("wx-close").onclick = closeWechatModal; +$("wechat-modal").addEventListener("click", (e) => { + if (e.target.id === "wechat-modal") closeWechatModal(); // 点遮罩关闭 +}); +$("wx-bind").onclick = bindFlow; +$("wx-unbind").onclick = async () => { + _polling = false; + if (!confirm("确定解绑微信?")) return; + try { + await api("DELETE", "/v1/wechat/bind"); + $("wx-qrbox").hidden = true; + await refresh(); + } catch (e) { setState("err", "解绑失败: " + e.message); } +}; +$("wx-test").onclick = async () => { + $("wx-test").disabled = true; + try { + const r = await api("POST", "/v1/wechat/test"); + if (r.ok) setState("ok", "测试消息已发送,去微信查收。"); + else setState("err", "推送未送达(" + r.reason + + ")。多半是超 24h 没在微信互动:发条消息再试。"); + } catch (e) { + setState("err", "测试失败: " + e.message); + } finally { $("wx-test").disabled = false; } +};