diff --git a/PROGRESS.md b/PROGRESS.md index 114dcd9..1de58e6 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-25(wechat_push 支持按渠道定向投递:点名企微/个微只发一条,不点名仍广播 + bump 0.27.1) +最后更新:2026-06-25(企业微信入站对话支持图片/文件附件:media/get 下载 → 复用渠道无关核心落盘 + bump 0.27.2) --- @@ -21,6 +21,12 @@ ## 已完成关键能力 +### 2026-06-25 / 企业微信入站对话支持图片/文件附件(bump 0.27.2) + +- 接续 0.27.0 企业微信入站(此前只收文本)。补图片/文件:`wecom.download_media(media_id)` 走 `media/get`(成功回二进制流 + Content-Disposition 文件名,出错回 JSON errcode、40014/42001 重取 token);回调按 `MsgType` 分支,image/file 下载后构造 `InboundAttachment(kind/file_name/data)`(与个人微信同结构,仅这三字段被用到)→ 喂同一 `_run_channel_conversation`,复用其落盘 + 拼 `[用户上传的...]` 行(图片 agent 自调 look_at_image,文件走 Read)。 +- 语音/视频/位置/链接/事件暂回 success 不处理;附件下载失败则静默跳过(打日志)。纯图片/文件消息无文本 → 核心据附件行生成 text,不再被「空消息」挡掉。 +- 文件:`core/wechat/wecom.py`(`download_media` + `_filename_from_disposition`)、`web/app.py`(回调 image/file 分支)、`web/static/dev.html`(「企业微信(仅推送)」→「推送 + 对话」文案纠正)。`_filename_from_disposition` + import 自测过。 + ### 2026-06-25 / wechat_push 按渠道定向投递(修「点名企微仍推到个微」,bump 0.27.1) - bug:用户说"推送给我的企业微信",消息却同时进了个人微信。根因 —— `send_to_user` 是无差别广播(`for ch in active_channels()` 逐个推),且 `wechat_push` 工具压根没有"指定渠道"的参数,agent 想只发企微也做不到;部署同时开了 clawbot+wecom 两渠道 → 一条推送两边都到。早期只有 clawbot 一渠道时此语义无碍,加企微后暴露。 diff --git a/RUN.md b/RUN.md index ae92ad6..822a356 100644 --- a/RUN.md +++ b/RUN.md @@ -77,7 +77,7 @@ - **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。 - **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。 - - **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**暂只收文本**;未绑定/空消息静默。 + - **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。 - **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 6357a70..9dee5fd 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.27.1" +__version__ = "0.27.2" diff --git a/core/wechat/wecom.py b/core/wechat/wecom.py index fee1bf4..be07f46 100644 --- a/core/wechat/wecom.py +++ b/core/wechat/wecom.py @@ -207,3 +207,46 @@ def upload_media(file_path: str | os.PathLike, *, media_type: str = "file") -> s def send_file(touser: str, file_path: str | os.PathLike) -> None: media_id = upload_media(file_path, media_type="file") _send(touser, "file", {"file": {"media_id": media_id}}) + + +# ─────────────────────────── 入站素材下载 ─────────────────────────── + +def _filename_from_disposition(disposition: str) -> str: + """从 Content-Disposition 取文件名(filename="..." / filename*=UTF-8''...);取不到回空。""" + if not disposition: + return "" + import re + from urllib.parse import unquote + m = re.search(r"filename\*=(?:UTF-8'')?([^;]+)", disposition, re.IGNORECASE) + if m: + return unquote(m.group(1).strip().strip('"')) + m = re.search(r'filename="?([^";]+)"?', disposition, re.IGNORECASE) + return m.group(1).strip() if m else "" + + +def download_media(media_id: str) -> tuple[bytes, str]: + """下载临时素材(`media/get`)→ (明文字节, 文件名)。入站图片/文件消息用。 + + 成功回二进制流(文件名在 Content-Disposition);出错回 JSON(errcode/errmsg)。 + 40014/42001(token 失效)自动重取一次。供回调线程 to_thread 调。 + """ + last = None + for attempt in (1, 2): + tok = get_access_token(force=(attempt == 2)) + with httpx.Client(timeout=60) as c: + r = c.get(f"{QYAPI}/media/get", + params={"access_token": tok, "media_id": media_id}) + r.raise_for_status() + ctype = r.headers.get("content-type", "").lower() + if "application/json" in ctype or "text/plain" in ctype: + try: + d = r.json() + except Exception: # noqa: BLE001 —— 非 JSON 当二进制处理 + d = None + if d is not None: + if d.get("errcode") in (40014, 42001) and attempt == 1: + continue + raise RuntimeError(f"media/get 失败: {d.get('errcode')} {d.get('errmsg')}") + fname = _filename_from_disposition(r.headers.get("content-disposition", "")) + return r.content, fname + raise RuntimeError(f"media/get 失败: token 重取后仍未拿到素材 {last}") diff --git a/web/app.py b/web/app.py index 1f80d5d..8b98045 100644 --- a/web/app.py +++ b/web/app.py @@ -1342,6 +1342,7 @@ def create_app() -> FastAPI: from fastapi.responses import PlainTextResponse from core.wechat import service as _wx from core.wechat import wecom, wecom_crypto + from core.wechat.ilink import InboundAttachment if not wecom_crypto.callback_configured(): raise HTTPException(404, "wecom callback 未配置(需 WECOM_CALLBACK_TOKEN/AESKEY)") body = (await request.body()).decode("utf-8") @@ -1351,18 +1352,41 @@ def create_app() -> FastAPI: ) except Exception as e: # noqa: BLE001 raise HTTPException(400, f"decrypt failed: {type(e).__name__}: {e}") - # 暂只处理文本消息;图片/语音/文件等回 success 防重试(后续可走 media/get 下载补齐) - if (msg.get("MsgType") or "") != "text": - return PlainTextResponse("success") - content = (msg.get("Content") or "").strip() + msgtype = msg.get("MsgType") or "" wuid = msg.get("FromUserName") or "" uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid) - if uid is None or not content: - return PlainTextResponse("success") # 未绑定 / 空消息 → 静默 + if uid is None: + return PlainTextResponse("success") # 未绑定 → 静默 - async def _bg(uid=uid, content=content): + # 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构, + # 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。其余类型(语音/视频/ + # 位置/链接/事件)暂不处理,回 success 防重试。 + content = "" + attachments: list = [] + if msgtype == "text": + content = (msg.get("Content") or "").strip() + elif msgtype in ("image", "file"): + media_id = msg.get("MediaId") or "" + if media_id: + try: + data, fname = await asyncio.to_thread(wecom.download_media, media_id) + attachments.append(InboundAttachment( + kind=("image" if msgtype == "image" else "file"), + media={}, + file_name=(msg.get("FileName") or fname or ""), + data=data, + )) + except Exception as e: # noqa: BLE001 + print(f"[wecom] {wuid} download {msgtype} err: {type(e).__name__}: {e}") + else: + return PlainTextResponse("success") + if not content and not attachments: + return PlainTextResponse("success") # 空消息 / 附件下载全失败 → 静默 + + async def _bg(uid=uid, content=content, attachments=attachments): try: - reply = await _run_channel_conversation(app, uid, content, None, channel="wecom") + reply = await _run_channel_conversation( + app, uid, content, attachments, channel="wecom") except Exception as e: # noqa: BLE001 reply = f"[出错] {type(e).__name__}: {e}" if reply and reply.strip(): diff --git a/web/static/dev.html b/web/static/dev.html index cd3fd6d..70a8298 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1340,8 +1340,8 @@
  • 绑定后先在微信给「微信 ClawBot」发句话,主动推送才开启(24h 窗口)。

  • -
    企业微信(仅推送)
    -
    无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。
    +
    企业微信(推送 + 对话)
    +
    无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。管理员在应用配「接收消息」回调后,还可在企业微信里直接和 zcbot 对话。
    加载中…