Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming 474597cfc6 feat(wecom): 企业微信入站对话支持图片/文件附件(media/get 下载 + 复用渠道无关核心)+ bump 0.27.2
接续 0.27.0 企业微信入站(此前只收文本)。

- wecom.download_media(media_id):走 media/get,成功回二进制流 + Content-Disposition
  文件名,出错回 JSON errcode(40014/42001 重取 token);_filename_from_disposition 解
  filename / filename* 两种形式。
- 回调按 MsgType 分支:image/file 下载后构造 InboundAttachment(kind/file_name/data,与
  个人微信同结构)→ 喂同一 _run_channel_conversation,复用其落盘 + 拼 [用户上传的...] 行
  (图片 agent 自调 look_at_image,文件走 Read)。纯图片/文件消息无文本时据附件行生成 text。
- 语音/视频/位置/链接/事件暂回 success 不处理;附件下载失败静默跳过(打日志)。
- dev.html「企业微信(仅推送)」文案纠正为「推送 + 对话」。

文件:core/wechat/wecom.py、web/app.py、web/static/dev.html。_filename_from_disposition
+ import 自测过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:17:44 +08:00
caoqianming d1aa2b12e2 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>
2026-06-25 12:15:36 +08:00
8 changed files with 129 additions and 21 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(企业微信入站对话支持图片/文件附件:media/get 下载 → 复用渠道无关核心落盘 + bump 0.27.2)
--- ---
@ -21,7 +21,17 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-25 / 企业微信支持入站对话(回调 webhook,bump 0.27.0) ### 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 一渠道时此语义无碍,加企微后暴露。
- 修:`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` 防重试。

2
RUN.md
View File

@ -77,7 +77,7 @@
- **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。 - **手填 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`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 - **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。 - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 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)。 - **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))"`。 - **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(见故障兜底)。 - **用户管理**(`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(见故障兜底)。

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.2"

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

@ -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: def send_file(touser: str, file_path: str | os.PathLike) -> None:
media_id = upload_media(file_path, media_type="file") media_id = upload_media(file_path, media_type="file")
_send(touser, "file", {"file": {"media_id": media_id}}) _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}")

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()

View File

@ -1342,6 +1342,7 @@ def create_app() -> FastAPI:
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from core.wechat import service as _wx from core.wechat import service as _wx
from core.wechat import wecom, wecom_crypto from core.wechat import wecom, wecom_crypto
from core.wechat.ilink import InboundAttachment
if not wecom_crypto.callback_configured(): if not wecom_crypto.callback_configured():
raise HTTPException(404, "wecom callback 未配置(需 WECOM_CALLBACK_TOKEN/AESKEY)") raise HTTPException(404, "wecom callback 未配置(需 WECOM_CALLBACK_TOKEN/AESKEY)")
body = (await request.body()).decode("utf-8") body = (await request.body()).decode("utf-8")
@ -1351,18 +1352,41 @@ def create_app() -> FastAPI:
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
raise HTTPException(400, f"decrypt failed: {type(e).__name__}: {e}") raise HTTPException(400, f"decrypt failed: {type(e).__name__}: {e}")
# 暂只处理文本消息;图片/语音/文件等回 success 防重试(后续可走 media/get 下载补齐) msgtype = msg.get("MsgType") or ""
if (msg.get("MsgType") or "") != "text":
return PlainTextResponse("success")
content = (msg.get("Content") or "").strip()
wuid = msg.get("FromUserName") or "" wuid = msg.get("FromUserName") or ""
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid) uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid)
if uid is None or not content: if uid is None:
return PlainTextResponse("success") # 未绑定 / 空消息 → 静默 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: 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 except Exception as e: # noqa: BLE001
reply = f"[出错] {type(e).__name__}: {e}" reply = f"[出错] {type(e).__name__}: {e}"
if reply and reply.strip(): if reply and reply.strip():

View File

@ -1340,8 +1340,8 @@
<li>绑定后先在微信给「微信 ClawBot」发句话,主动推送才开启(24h 窗口)。</li> <li>绑定后先在微信给「微信 ClawBot」发句话,主动推送才开启(24h 窗口)。</li>
</ul> </ul>
<hr style="border:none;border-top:1px solid var(--line,#d0d7de);margin:18px 0;"> <hr style="border:none;border-top:1px solid var(--line,#d0d7de);margin:18px 0;">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">企业微信(推送)</div> <div style="font-weight:600;font-size:14px;margin-bottom:6px;">企业微信(推送 + 对话)</div>
<div class="muted" style="font-size:12px;margin-bottom:10px;">无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。</div> <div class="muted" style="font-size:12px;margin-bottom:10px;">无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。管理员在应用配「接收消息」回调后,还可在企业微信里直接和 zcbot 对话。</div>
<div id="wc-state" class="wx-status wait">加载中…</div> <div id="wc-state" class="wx-status wait">加载中…</div>
<div class="wx-acts"> <div class="wx-acts">
<button id="wc-bind" class="small">扫码绑定</button> <button id="wc-bind" class="small">扫码绑定</button>