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:
parent
d16297e556
commit
d1aa2b12e2
|
|
@ -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` 防重试。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.27.0"
|
||||
__version__ = "0.27.1"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue