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>
This commit is contained in:
caoqianming 2026-06-25 12:17:44 +08:00
parent d1aa2b12e2
commit 474597cfc6
6 changed files with 86 additions and 13 deletions

View File

@ -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 一渠道时此语义无碍,加企微后暴露。

2
RUN.md
View File

@ -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(见故障兜底)。

View File

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

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:
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}")

View File

@ -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:
reply = await _run_channel_conversation(app, uid, content, None, channel="wecom")
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, attachments, channel="wecom")
except Exception as e: # noqa: BLE001
reply = f"[出错] {type(e).__name__}: {e}"
if reply and reply.strip():

View File

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