fix(wecom): wechat_push 支持按渠道定向投递,修「点名企微仍推到个微」+ bump 0.27.1

用户说"推送给我的企业微信",消息却同时进了个人微信。根因: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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-25 12:15:36 +08:00
parent d16297e556
commit d1aa2b12e2
4 changed files with 45 additions and 10 deletions

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-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 定位"和邮箱似的");现补**入站对话**,企微也能像个人微信那样直接聊。 - 需求:企业微信此前只做出站推送(渠道 B 定位"和邮箱似的");现补**入站对话**,企微也能像个人微信那样直接聊。
- 关键认知 —— 入站方式与 ClawBot 不同:ClawBot 走**长轮询**(`getupdates` + `run_inbound_manager` 常驻),企业微信走**回调 webhook**(企微服务器主动 POST 加密 XML),故**不需要后台轮询 task**,只加一个 HTTP 端点。回复因 agent 跑 >5s 超被动同步窗口 → 走 `message/send` 主动推回(复用 `push_wecom`),被动回复直接回 `success` 防重试。 - 关键认知 —— 入站方式与 ClawBot 不同:ClawBot 走**长轮询**(`getupdates` + `run_inbound_manager` 常驻),企业微信走**回调 webhook**(企微服务器主动 POST 加密 XML),故**不需要后台轮询 task**,只加一个 HTTP 端点。回复因 agent 跑 >5s 超被动同步窗口 → 走 `message/send` 主动推回(复用 `push_wecom`),被动回复直接回 `success` 防重试。

View File

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

View File

@ -305,10 +305,24 @@ _DISPATCH = {_CLAWBOT: push_clawbot, _WECOM: push_wecom}
def send_to_user( 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: ) -> DeliveryReport:
"""渠道抽象:按 `active_channels()` 列出的已开渠道依次投递。""" """渠道抽象:按 `active_channels()` 列出的已开渠道投递。
- `channel=None`(默认):广播到所有已开渠道(定时任务/不点名推送沿用此口径)
- `channel="wecom"|"clawbot"`:用户点名某个微信时只投这一条;若该渠道未开/无效,
返回单条 `no_binding` 结果(不静默回退到别的渠道,避免又推到没点名的渠道)
"""
report = DeliveryReport() 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(): for ch in active_channels():
report.results.append(_DISPATCH[ch](user_id, text, file_path)) report.results.append(_DISPATCH[ch](user_id, text, file_path))
return report return report

View File

@ -36,9 +36,12 @@ class WechatPushTool(Tool):
"Proactively push a short text message (and optionally one result file, e.g. a .docx/.pdf " "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 " "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 " "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 " "deliver its output there. IMPORTANT: if the user names a specific WeChat — '企业微信'/'企微'/"
"WeChat (ClawBot) channel — WeCom (企业微信) has no window limit. If it returns a " "WeCom → channel='wecom'; '个人微信'/'微信' (personal, via ClawBot) → channel='clawbot' — pass "
"window/binding error, fall back to send_email. The file path is relative to the working directory." "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 = { parameters = {
"type": "object", "type": "object",
@ -48,6 +51,14 @@ class WechatPushTool(Tool):
"type": "string", "type": "string",
"description": "Optional path (relative to working dir) of one file to attach, e.g. 'report.docx'.", "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"], "required": ["text"],
} }
@ -56,11 +67,17 @@ class WechatPushTool(Tool):
super().__init__(base_dir=base_dir, user_root=user_root) super().__init__(base_dir=base_dir, user_root=user_root)
self.user_id = user_id 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() text = (text or "").strip()
if not text and not file: if not text and not file:
return "[Error] text 不能为空" 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 fpath: Optional[str] = None
if file and file.strip(): if file and file.strip():
# host-side 解析:容器 /workspace 路径翻回宿主 + 强制落 user_root 内(防越界) # host-side 解析:容器 /workspace 路径翻回宿主 + 强制落 user_root 内(防越界)
@ -72,7 +89,7 @@ class WechatPushTool(Tool):
return f"[Error] 文件不存在: {file}" return f"[Error] 文件不存在: {file}"
fpath = str(p) 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: if report.delivered:
n = "(含 1 个文件)" if fpath else "" n = "(含 1 个文件)" if fpath else ""
return f"[ok] 已推送到微信 {n}".strip() return f"[ok] 已推送到微信 {n}".strip()