Compare commits
No commits in common. "474597cfc6557dc316c0eb2fce6df96343dc4140" and "d16297e556eb1a31d8893b4f697fdba6c5b7afc1" have entirely different histories.
474597cfc6
...
d16297e556
14
PROGRESS.md
14
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-25(企业微信入站对话支持图片/文件附件:media/get 下载 → 复用渠道无关核心落盘 + bump 0.27.2)
|
||||
最后更新:2026-06-25(企业微信支持入站对话:回调 webhook + AES 解密 + 复用渠道无关对话核心 + bump 0.27.0)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,17 +21,7 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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`。
|
||||
### 2026-06-25 / 企业微信支持入站对话(回调 webhook,bump 0.27.0)
|
||||
|
||||
- 需求:企业微信此前只做出站推送(渠道 B 定位"和邮箱似的");现补**入站对话**,企微也能像个人微信那样直接聊。
|
||||
- 关键认知 —— 入站方式与 ClawBot 不同:ClawBot 走**长轮询**(`getupdates` + `run_inbound_manager` 常驻),企业微信走**回调 webhook**(企微服务器主动 POST 加密 XML),故**不需要后台轮询 task**,只加一个 HTTP 端点。回复因 agent 跑 >5s 超被动同步窗口 → 走 `message/send` 主动推回(复用 `push_wecom`),被动回复直接回 `success` 防重试。
|
||||
|
|
|
|||
2
RUN.md
2
RUN.md
|
|
@ -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 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
|
||||
- **入站对话(可选,要公网 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 限制)。**暂只收文本**;未绑定/空消息静默。
|
||||
- **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(见故障兜底)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.27.2"
|
||||
__version__ = "0.27.0"
|
||||
|
|
|
|||
|
|
@ -305,24 +305,10 @@ _DISPATCH = {_CLAWBOT: push_clawbot, _WECOM: push_wecom}
|
|||
|
||||
|
||||
def send_to_user(
|
||||
user_id: UUID,
|
||||
text: str = "",
|
||||
file_path: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
user_id: UUID, text: str = "", file_path: Optional[str] = None
|
||||
) -> DeliveryReport:
|
||||
"""渠道抽象:按 `active_channels()` 列出的已开渠道投递。
|
||||
|
||||
- `channel=None`(默认):广播到所有已开渠道(定时任务/不点名推送沿用此口径)。
|
||||
- `channel="wecom"|"clawbot"`:用户点名某个微信时只投这一条;若该渠道未开/无效,
|
||||
返回单条 `no_binding` 结果(不静默回退到别的渠道,避免又推到没点名的渠道)。
|
||||
"""
|
||||
"""渠道抽象:按 `active_channels()` 列出的已开渠道依次投递。"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -207,46 +207,3 @@ 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}")
|
||||
|
|
|
|||
|
|
@ -36,12 +36,9 @@ 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. 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."
|
||||
"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."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
|
|
@ -51,14 +48,6 @@ 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"],
|
||||
}
|
||||
|
|
@ -67,17 +56,11 @@ 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, channel: Optional[str] = None
|
||||
) -> str:
|
||||
def execute(self, text: str = "", file: 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 内(防越界)
|
||||
|
|
@ -89,7 +72,7 @@ class WechatPushTool(Tool):
|
|||
return f"[Error] 文件不存在: {file}"
|
||||
fpath = str(p)
|
||||
|
||||
report = service.send_to_user(self.user_id, text, fpath, channel=channel)
|
||||
report = service.send_to_user(self.user_id, text, fpath)
|
||||
if report.delivered:
|
||||
n = "(含 1 个文件)" if fpath else ""
|
||||
return f"[ok] 已推送到微信 {n}".strip()
|
||||
|
|
|
|||
40
web/app.py
40
web/app.py
|
|
@ -1342,7 +1342,6 @@ 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")
|
||||
|
|
@ -1352,41 +1351,18 @@ def create_app() -> FastAPI:
|
|||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(400, f"decrypt failed: {type(e).__name__}: {e}")
|
||||
msgtype = msg.get("MsgType") or ""
|
||||
# 暂只处理文本消息;图片/语音/文件等回 success 防重试(后续可走 media/get 下载补齐)
|
||||
if (msg.get("MsgType") or "") != "text":
|
||||
return PlainTextResponse("success")
|
||||
content = (msg.get("Content") or "").strip()
|
||||
wuid = msg.get("FromUserName") or ""
|
||||
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid)
|
||||
if uid is None:
|
||||
return PlainTextResponse("success") # 未绑定 → 静默
|
||||
if uid is None or not content:
|
||||
return PlainTextResponse("success") # 未绑定 / 空消息 → 静默
|
||||
|
||||
# 文本取 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):
|
||||
async def _bg(uid=uid, content=content):
|
||||
try:
|
||||
reply = await _run_channel_conversation(
|
||||
app, uid, content, attachments, channel="wecom")
|
||||
reply = await _run_channel_conversation(app, uid, content, None, channel="wecom")
|
||||
except Exception as e: # noqa: BLE001
|
||||
reply = f"[出错] {type(e).__name__}: {e}"
|
||||
if reply and reply.strip():
|
||||
|
|
|
|||
|
|
@ -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 窗口),适合定时简报必达。扫码授权一次拿成员身份。管理员在应用配「接收消息」回调后,还可在企业微信里直接和 zcbot 对话。</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 id="wc-state" class="wx-status wait">加载中…</div>
|
||||
<div class="wx-acts">
|
||||
<button id="wc-bind" class="small">扫码绑定</button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue