From d1aa2b12e2090774f7548f8c05353fb2905508fc Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 25 Jun 2026 12:15:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(wecom):=20wechat=5Fpush=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=89=E6=B8=A0=E9=81=93=E5=AE=9A=E5=90=91=E6=8A=95?= =?UTF-8?q?=E9=80=92,=E4=BF=AE=E3=80=8C=E7=82=B9=E5=90=8D=E4=BC=81?= =?UTF-8?q?=E5=BE=AE=E4=BB=8D=E6=8E=A8=E5=88=B0=E4=B8=AA=E5=BE=AE=E3=80=8D?= =?UTF-8?q?+=20bump=200.27.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户说"推送给我的企业微信",消息却同时进了个人微信。根因:send_to_user 是无差别广播(for ch in active_channels() 逐个推),且 wechat_push 工具 没有指定渠道的参数 —— 部署同开 clawbot+wecom 时一条推送两边都到。 - send_to_user 加 channel=None:None 保持广播(定时任务/不点名沿用,向后 兼容);指定 wecom/clawbot 时只投那一条,该渠道未开返回单条 no_binding, 不静默回退到别的渠道。 - WechatPushTool 加可选 channel(enum wecom/clawbot)+ 描述教 agent 「用户点名某微信就传对应 channel」,execute 做渠道白名单校验。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 8 ++++++-- core/__init__.py | 2 +- core/wechat/service.py | 18 ++++++++++++++++-- tools/wechat_bot.py | 27 ++++++++++++++++++++++----- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index b2cd3fb..114dcd9 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(企业微信支持入站对话:回调 webhook + AES 解密 + 复用渠道无关对话核心 + bump 0.27.0) +最后更新:2026-06-25(wechat_push 支持按渠道定向投递:点名企微/个微只发一条,不点名仍广播 + bump 0.27.1) --- @@ -21,7 +21,11 @@ ## 已完成关键能力 -### 2026-06-25 / 企业微信支持入站对话(回调 webhook,bump 0.27.0) +### 2026-06-25 / wechat_push 按渠道定向投递(修「点名企微仍推到个微」,bump 0.27.1) + +- bug:用户说"推送给我的企业微信",消息却同时进了个人微信。根因 —— `send_to_user` 是无差别广播(`for ch in active_channels()` 逐个推),且 `wechat_push` 工具压根没有"指定渠道"的参数,agent 想只发企微也做不到;部署同时开了 clawbot+wecom 两渠道 → 一条推送两边都到。早期只有 clawbot 一渠道时此语义无碍,加企微后暴露。 +- 修:`send_to_user` 加 `channel=None` 入参 —— `None` 保持广播(定时任务/不点名沿用,向后兼容),指定 `wecom`/`clawbot` 时只投那一条(该渠道未开则返回单条 `no_binding`,**不静默回退到别的渠道**避免又推错);`WechatPushTool` 加可选 `channel`(enum wecom/clawbot)+ 描述教 agent「用户点名某微信就传对应 channel」。 +- 文件:`core/wechat/service.py`、`tools/wechat_bot.py`。 - 需求:企业微信此前只做出站推送(渠道 B 定位"和邮箱似的");现补**入站对话**,企微也能像个人微信那样直接聊。 - 关键认知 —— 入站方式与 ClawBot 不同:ClawBot 走**长轮询**(`getupdates` + `run_inbound_manager` 常驻),企业微信走**回调 webhook**(企微服务器主动 POST 加密 XML),故**不需要后台轮询 task**,只加一个 HTTP 端点。回复因 agent 跑 >5s 超被动同步窗口 → 走 `message/send` 主动推回(复用 `push_wecom`),被动回复直接回 `success` 防重试。 diff --git a/core/__init__.py b/core/__init__.py index ba42921..6357a70 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.27.0" +__version__ = "0.27.1" diff --git a/core/wechat/service.py b/core/wechat/service.py index d75f4da..b1e883b 100644 --- a/core/wechat/service.py +++ b/core/wechat/service.py @@ -305,10 +305,24 @@ _DISPATCH = {_CLAWBOT: push_clawbot, _WECOM: push_wecom} def send_to_user( - user_id: UUID, text: str = "", file_path: Optional[str] = None + user_id: UUID, + text: str = "", + file_path: Optional[str] = None, + channel: Optional[str] = None, ) -> DeliveryReport: - """渠道抽象:按 `active_channels()` 列出的已开渠道依次投递。""" + """渠道抽象:按 `active_channels()` 列出的已开渠道投递。 + + - `channel=None`(默认):广播到所有已开渠道(定时任务/不点名推送沿用此口径)。 + - `channel="wecom"|"clawbot"`:用户点名某个微信时只投这一条;若该渠道未开/无效, + 返回单条 `no_binding` 结果(不静默回退到别的渠道,避免又推到没点名的渠道)。 + """ report = DeliveryReport() + if channel is not None: + if channel in active_channels(): + report.results.append(_DISPATCH[channel](user_id, text, file_path)) + else: + report.results.append(PushResult(False, channel=channel, reason="no_binding")) + return report for ch in active_channels(): report.results.append(_DISPATCH[ch](user_id, text, file_path)) return report diff --git a/tools/wechat_bot.py b/tools/wechat_bot.py index 9f5a612..2de8a4f 100644 --- a/tools/wechat_bot.py +++ b/tools/wechat_bot.py @@ -36,9 +36,12 @@ class WechatPushTool(Tool): "Proactively push a short text message (and optionally one result file, e.g. a .docx/.pdf " "report) to the user's bound WeChat (personal WeChat via ClawBot, or WeCom/企业微信). Use " "when the user asks to send something to their WeChat, or when a scheduled task should " - "deliver its output there. NOTE: the ~24h-window constraint applies ONLY to the personal " - "WeChat (ClawBot) channel — WeCom (企业微信) has no window limit. If it returns a " - "window/binding error, fall back to send_email. The file path is relative to the working directory." + "deliver its output there. IMPORTANT: if the user names a specific WeChat — '企业微信'/'企微'/" + "WeCom → channel='wecom'; '个人微信'/'微信' (personal, via ClawBot) → channel='clawbot' — pass " + "that channel so it ONLY goes there. Omit channel to broadcast to all bound WeChat channels. " + "NOTE: the ~24h-window constraint applies ONLY to the personal WeChat (ClawBot) channel — WeCom " + "(企业微信) has no window limit. If it returns a window/binding error, fall back to send_email. " + "The file path is relative to the working directory." ) parameters = { "type": "object", @@ -48,6 +51,14 @@ class WechatPushTool(Tool): "type": "string", "description": "Optional path (relative to working dir) of one file to attach, e.g. 'report.docx'.", }, + "channel": { + "type": "string", + "enum": ["wecom", "clawbot"], + "description": ( + "Optional. Target a single WeChat channel: 'wecom' = WeCom/企业微信, " + "'clawbot' = personal WeChat. Omit to broadcast to all bound channels." + ), + }, }, "required": ["text"], } @@ -56,11 +67,17 @@ class WechatPushTool(Tool): 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: + def execute( + self, text: str = "", file: Optional[str] = None, channel: Optional[str] = None + ) -> str: text = (text or "").strip() if not text and not file: return "[Error] text 不能为空" + channel = (channel or "").strip().lower() or None + if channel is not None and channel not in ("wecom", "clawbot"): + return f"[Error] 未知渠道: {channel}(只支持 wecom / clawbot)" + fpath: Optional[str] = None if file and file.strip(): # host-side 解析:容器 /workspace 路径翻回宿主 + 强制落 user_root 内(防越界) @@ -72,7 +89,7 @@ class WechatPushTool(Tool): return f"[Error] 文件不存在: {file}" fpath = str(p) - report = service.send_to_user(self.user_id, text, fpath) + report = service.send_to_user(self.user_id, text, fpath, channel=channel) if report.delivered: n = "(含 1 个文件)" if fpath else "" return f"[ok] 已推送到微信 {n}".strip()