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:
parent
d1aa2b12e2
commit
474597cfc6
|
|
@ -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(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)
|
### 2026-06-25 / wechat_push 按渠道定向投递(修「点名企微仍推到个微」,bump 0.27.1)
|
||||||
|
|
||||||
- bug:用户说"推送给我的企业微信",消息却同时进了个人微信。根因 —— `send_to_user` 是无差别广播(`for ch in active_channels()` 逐个推),且 `wechat_push` 工具压根没有"指定渠道"的参数,agent 想只发企微也做不到;部署同时开了 clawbot+wecom 两渠道 → 一条推送两边都到。早期只有 clawbot 一渠道时此语义无碍,加企微后暴露。
|
- bug:用户说"推送给我的企业微信",消息却同时进了个人微信。根因 —— `send_to_user` 是无差别广播(`for ch in active_channels()` 逐个推),且 `wechat_push` 工具压根没有"指定渠道"的参数,agent 想只发企微也做不到;部署同时开了 clawbot+wecom 两渠道 → 一条推送两边都到。早期只有 clawbot 一渠道时此语义无碍,加企微后暴露。
|
||||||
|
|
|
||||||
2
RUN.md
2
RUN.md
|
|
@ -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(见故障兜底)。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.27.1"
|
__version__ = "0.27.2"
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
40
web/app.py
40
web/app.py
|
|
@ -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:
|
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
|
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():
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue