diff --git a/DESIGN.md b/DESIGN.md index 1c77afe..3c250ed 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -706,6 +706,7 @@ create index on usage_events (model_profile, created_at); **协议要点(自实现客户端,2026-06-23 实测验证)**:base = 绑定返回的 `base_url`(实测 `https://ilinkai.weixin.qq.com`)。所有请求 header:`Content-Type: application/json` + `AuthorizationType: ilink_bot_token` + **`X-WECHAT-UIN` 每请求变**(`base64(随机uint32)`,反重放);除取码/查状态外加 `Authorization: Bearer `。 - **取码/绑定**:`GET /ilink/bot/get_bot_qrcode?bot_type=3`(无需任何预置凭据)→ `{qrcode, qrcode_img_content}`,`qrcode_img_content` 是**微信深链**(`liteapp.weixin.qq.com/q/...`),需**自渲成二维码**(非图片直链);`GET /ilink/bot/get_qrcode_status?qrcode=`(长轮询)→ `{status: wait|confirmed|expired, bot_token, baseurl}`。二维码 TTL 短(~1min),实现要**过期自动换码**。 - **收**:`POST /ilink/bot/getupdates`,body `{get_updates_buf:<游标,首次空>, base_info:{channel_version:"1.0.2"}}`(长轮询 hold ≤35s)→ `{msgs:[{from_user_id, context_token, item_list:[{type:1,text_item:{text}}]}], get_updates_buf}`。 +- **收图片/文件(2026-06-24)**:`item_list` 项除 `text_item` 外还有 `image_item`(type=2,带 `media{encrypt_query_param, aes_key, encrypt_type}` + 优先 `aeskey` 32-hex)、`file_item`(type=4,带 `media` + `file_name` + `len`);**下载是文件发送(下条)的逆操作**——`GET {cdn_base}/download?encrypted_query_param=` 取密文 → **AES-128-ECB+PKCS7 解密**(key 优先图片 `aeskey`,否则 `media.aes_key` 两种编码兜底:base64(raw16) / base64(hex32))。落盘 `/inbound/`,图片拼 `[用户上传的参考图]`(走 `look_at_image`)、文件拼 `[用户上传的文件]`(走 Read/Shell)注入 user 消息,**复用 web 端粘贴图约定,不碰模型链路**。⚠️ 下载 GET/POST 与 aes_key 取支待真机端到端校(crypto 单测已过)。 - **发**:`POST /ilink/bot/sendmessage`,body `{msg:{to_user_id, client_id:<每条唯一>, message_type:2, message_state:1|2, context_token, item_list:[...]}, base_info:{channel_version:"1.0.2"}}`。**`client_id` 必带且每条唯一**(否则同 token 后续消息被丢);多条/长文 → 中间块 `message_state=1`、末块 `=2`,~1000 字/块、间隔 ~300ms。成功返回 HTTP 200 + 空 body `{}`(无 ret,不能据 body 判成败,以实投为准)。 - **token 生命周期**:`context_token` 有效期 ~24h、可复用(发完 FINISH 仍可再发)→ 主动推送靠它;**每条入站消息刷新**该用户 token(存最新值 + 时间戳)。`bot_token` 长期 per-user 凭据(扫码下发)。 - **文件发送(2026-06-23 实测通,`scripts/probe_clawbot_file.py`)**:①`POST /ilink/bot/getuploadurl`(body `{filekey:随机16B的hex, media_type:3(FILE)/1(IMAGE), to_user_id, rawsize, rawfilemd5, filesize:PKCS7填充后大小, aeskey:随机16B的hex, no_need_thumb:true, base_info}`)→ 返回 `{upload_param}`;② 本地用该 aeskey 做 **AES-128-ECB + PKCS7** 加密文件;③ `POST {cdn_base}/upload?encrypted_query_param=&filekey=`(`cdn_base=https://novac2c.cdn.weixin.qq.com/c2c`,body=密文、`application/octet-stream`)→ **响应头 `x-encrypted-param`** = 下载引用(漏 `&filekey=` 会 400 `filekey mismatch`);④ `sendmessage` 带 `item_list:[{type:4, file_item:{media:{encrypt_query_param:<上一步 x-encrypted-param>, aes_key:base64(aeskey.hex()的ascii字节), encrypt_type:1}, file_name, len:str(rawsize)}}]`。**docx/pdf 简报可原生直推为可打开附件**,无须退下载链接。 diff --git a/PROGRESS.md b/PROGRESS.md index 74772bc..bb4876e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3) +最后更新:2026-06-24(微信入站收图片/文件:CDN 下载+AES 解密落盘,复用 [用户上传的参考图] 约定喂 look_at_image + bump 0.25.0) --- @@ -21,6 +21,13 @@ ## 已完成关键能力 +### 2026-06-24 / 微信入站收图片/文件(bump 0.25.0) +- 缺口:`ILinkClient.get_updates` 只抽 `text_item`,图片/文件 item 被丢成空 text → `inbound._poll_binding` 又因空文本 `continue`,用户发的图/文件**静默丢弃、零落库**(DB 实证:caoqianming@foxmail.com 的微信 task 里发的图无任何记录)。 +- `core/wechat/ilink.py`:新 `InboundAttachment`(kind/media/file_name/aeskey_hex/data);`get_updates` 解析 `image_item`(type=2)/`file_item`(type=4);新 `download_media()` = CDN `/c2c/download?encrypted_query_param=...` GET 密文 → `_aes_ecb_unpkcs7`(AES-128-ECB 解,发送侧 `_aes_ecb_pkcs7` 的逆);key 两种编码兜底 `_decode_media_aes_key`(base64(raw16) / base64(hex32),后者同发送侧);图片无名按 magic bytes 补扩展名 `_guess_image_ext` + `attachment_basename`(剥路径防穿越)。 +- `core/wechat/inbound.py`:`HandleMessage` 契约加第三参 attachments;`_poll_binding` 先下载解密回填 `att.data`,文本/附件**都空才跳过**(单附件下载失败不拖垮整条)。 +- `web/app.py:_run_wechat_message`:附件落盘 `/inbound/--`,图片拼 `[用户上传的参考图] `(agent 自调 `look_at_image` 看图)、文件拼 `[用户上传的文件] `(agent 用 Read/Shell),**复用 web 端粘贴图同一约定**,不碰模型链路。 +- 协议下载分支(GET vs POST、aes_key 取哪支)有真机实测风险:crypto roundtrip + 双编码 key decode 已单测通过;端到端待用户重发一张图验证(原图 cursor 已过)。 + ### 2026-06-24 / 微信绑定表重构:两表合一 channel_bindings(判别列+JSONB,bump 0.24.3) - 起因:ClawBot(0012 `wechat_bot_bindings`,8 列)+ 企微(0014 `wecom_bindings`,1 列)各一表。从架构角度复盘:渠道绑定本质="用户在某渠道的一份配置",各渠道字段形态不同 → 最优是**判别列 + JSONB 多态**(与本库 `usage_events` kind+units / `scheduled_jobs.notify` 同范式),加渠道(飞书/TG…)零 migration。分表不扛增长、与库内范式不一致;单宽表(NULL 列并列)最差。 diff --git a/core/__init__.py b/core/__init__.py index 0e19b7e..2606f84 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.24.4" +__version__ = "0.25.0" diff --git a/core/wechat/ilink.py b/core/wechat/ilink.py index 7385e8c..d5fddaa 100644 --- a/core/wechat/ilink.py +++ b/core/wechat/ilink.py @@ -19,7 +19,7 @@ import hashlib import os import time import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Optional from urllib.parse import quote @@ -80,6 +80,46 @@ def _aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes: return enc.update(padded) + enc.finalize() +def _aes_ecb_unpkcs7(ciphertext: bytes, key: bytes) -> bytes: + """收图/收文件的解密:AES-128-ECB 解 + 去 PKCS7(发送侧 `_aes_ecb_pkcs7` 的逆)。""" + dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor() + padded = dec.update(ciphertext) + dec.finalize() + unpadder = padding.PKCS7(128).unpadder() + return unpadder.update(padded) + unpadder.finalize() + + +def _decode_media_aes_key(raw: str) -> bytes: + """媒体 `media.aes_key` → 16 字节 AES key。两种实测编码兜住: + - `base64(raw 16 bytes)`(图片常见)→ 解码得 16 字节直用; + - `base64(hex 字符串)`(文件/语音/视频,发送侧 `_upload_file` 也用这种)→ 解码得 + 32 个 ASCII hex 字符,再 `fromhex` 成 16 字节。 + """ + dec = base64.b64decode(raw) + if len(dec) == 16: + return dec + if len(dec) == 32: + try: + return bytes.fromhex(dec.decode("ascii")) + except (ValueError, UnicodeDecodeError): + return dec[:16] + return dec[:16] + + +def _guess_image_ext(data: bytes) -> str: + """按 magic bytes 猜图片扩展名(微信入站图片无原文件名)。认不出回退 .jpg。""" + if data[:3] == b"\xff\xd8\xff": + return ".jpg" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return ".png" + if data[:6] in (b"GIF87a", b"GIF89a"): + return ".gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + if data[:2] == b"BM": + return ".bmp" + return ".jpg" + + # ─────────────────────────── 绑定(无 token)─────────────────────────── @dataclass @@ -130,12 +170,30 @@ def poll_qrcode_status( # ─────────────────────────── 收发(带 token)─────────────────────────── +@dataclass +class InboundAttachment: + """入站附件(图片 / 文件)的 CDN 引用 + 下载后填充的明文字节。 + + 协议结构(getupdates 返回的 item_list 项,实测 + 逆向 photon-hq/wechat-ilink-client): + - 图片 `image_item`(type=2):`media{encrypt_query_param, aes_key, encrypt_type}`, + 另带优先 `aeskey`(32 位 hex);文件名缺失,下载后按 magic bytes 补扩展名。 + - 文件 `file_item`(type=4):`media{...}` + `file_name` + `len`(明文大小)。 + """ + kind: str # "image" | "file" + media: dict[str, Any] # {encrypt_query_param, aes_key, encrypt_type} + file_name: str = "" # 文件原名(图片无名,落盘时按 magic bytes 生成) + aeskey_hex: str = "" # 图片优先 key:image_item.aeskey(32 hex chars) + size: int = 0 # 明文大小(file_item.len / image mid_size),仅参考 + data: Optional[bytes] = None # 下载 + 解密后的明文,由调用方(inbound)回填 + + @dataclass class InboundMessage: from_user_id: str # xxx@im.wechat context_token: str # 回复 / 24h 内主动推须带回 text: str raw: dict[str, Any] + attachments: list[InboundAttachment] = field(default_factory=list) class ILinkClient: @@ -160,18 +218,62 @@ class ILinkClient: d = r.json() msgs: list[InboundMessage] = [] for m in d.get("msgs", []) or []: - text = "".join( - (it.get("text_item", {}) or {}).get("text", "") - for it in m.get("item_list", []) or [] - ) + text_parts: list[str] = [] + attachments: list[InboundAttachment] = [] + for it in m.get("item_list", []) or []: + if it.get("text_item"): + text_parts.append((it["text_item"] or {}).get("text", "")) + img = it.get("image_item") + if img: + attachments.append(InboundAttachment( + kind="image", + media=img.get("media") or {}, + aeskey_hex=(img.get("aeskey") or ""), + size=int(img.get("mid_size") or 0), + )) + fil = it.get("file_item") + if fil: + attachments.append(InboundAttachment( + kind="file", + media=fil.get("media") or {}, + file_name=(fil.get("file_name") or "file"), + size=int(fil.get("len") or 0), + )) msgs.append(InboundMessage( from_user_id=m.get("from_user_id", ""), context_token=m.get("context_token", ""), - text=text, + text="".join(text_parts), raw=m, + attachments=attachments, )) return msgs, d.get("get_updates_buf", cursor) + # —— 收附件(CDN 下载 → AES-128-ECB 解密 → 明文 bytes)—— + def download_media(self, att: InboundAttachment, *, timeout: float = 60.0) -> bytes: + """下载并解密一个入站附件,返回明文 bytes(发送侧上传链路的逆操作)。 + + URL:`{CDN_BASE}/download?encrypted_query_param=`。 + Key 优先级:图片 `image_item.aeskey`(32 hex)> `media.aes_key`(两种编码,见 + `_decode_media_aes_key`)。 + """ + media = att.media or {} + qp = media.get("encrypt_query_param") or media.get("encrypted_query_param") or "" + if not qp: + raise RuntimeError(f"附件无 encrypt_query_param: kind={att.kind} media={media}") + url = f"{CDN_BASE}/download?encrypted_query_param={quote(qp)}" + with httpx.Client(timeout=timeout) as c: + # 下载语义按逆向文档是 GET;CDN 若只认 POST 则回退一次(下载幂等,无副作用) + r = c.get(url) + if r.status_code == 405 or (400 <= r.status_code < 500 and not r.content): + r = c.post(url, content=b"") + r.raise_for_status() + ciphertext = r.content + if att.aeskey_hex and len(att.aeskey_hex) == 32: + key = bytes.fromhex(att.aeskey_hex) + else: + key = _decode_media_aes_key(media.get("aes_key") or "") + return _aes_ecb_unpkcs7(ciphertext, key) + # —— 发(底层单条)—— def _send( self, to_user_id: str, context_token: str, item: dict, *, state: int @@ -289,6 +391,18 @@ class ILinkClient: self._send(to_user_id, context_token, item, state=STATE_FINISH) +def attachment_basename(att: InboundAttachment) -> str: + """入站附件的安全落盘文件名(不含目录):剥掉路径分隔防穿越;图片按 magic bytes 补扩展名。 + + 返回的是 basename,调用方负责加前缀(时间戳 / 随机)防重名并拼到 inbound 目录下。 + """ + if att.kind == "image": + ext = _guess_image_ext(att.data or b"") + return f"image{ext}" + name = os.path.basename((att.file_name or "file").replace("\\", "/")).strip() + return name or "file" + + def _read_file_capped(file_path: str | os.PathLike) -> bytes: size = os.path.getsize(file_path) if size > MAX_FILE_BYTES: diff --git a/core/wechat/inbound.py b/core/wechat/inbound.py index ce386a1..9578e1b 100644 --- a/core/wechat/inbound.py +++ b/core/wechat/inbound.py @@ -21,11 +21,12 @@ from sqlalchemy import select from core.storage import session_scope from core.storage.models import Message from core.wechat import service -from core.wechat.ilink import ILinkClient +from core.wechat.ilink import ILinkClient, InboundAttachment from core.wechat.service import BindingSnapshot -# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空) -HandleMessage = Callable[[UUID, str], Awaitable[str]] +# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)。 +# 第三参 attachments:已下载解密(att.data 已回填)的入站附件,app.py 负责落盘 + 拼提示行。 +HandleMessage = Callable[[UUID, str, list[InboundAttachment]], Awaitable[str]] def _content_to_text(content: Any) -> str: @@ -79,15 +80,25 @@ async def _poll_binding( for m in msgs: if stop.is_set(): break - if not m.text.strip(): + # 下载入站附件(图片/文件):CDN 取密文 → AES 解密 → 回填 att.data + atts: list[InboundAttachment] = [] + for att in m.attachments: + try: + att.data = await asyncio.to_thread(client.download_media, att) + atts.append(att) + except Exception as e: # noqa: BLE001 + print(f"[wechat-inbound] {str(snap.user_id)[:8]} download " + f"{att.kind} err: {type(e).__name__}: {e}") + # 文本和附件都没有(纯文本为空 / 附件全下载失败)→ 跳过整条 + if not m.text.strip() and not atts: continue # ① 刷新该用户推送窗口(主动推靠它续命) await asyncio.to_thread( service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token ) - # ② 跑 agent 取回复 + # ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行) try: - reply = await handle_message(snap.user_id, m.text) + reply = await handle_message(snap.user_id, m.text, atts) except Exception as e: # noqa: BLE001 reply = f"[出错] {type(e).__name__}: {e}" # ③ 用本轮新鲜 token 分块回 diff --git a/web/app.py b/web/app.py index f19dd3d..d472053 100644 --- a/web/app.py +++ b/web/app.py @@ -856,11 +856,17 @@ def create_app() -> FastAPI: wechat_stop = asyncio.Event() wechat_task = None - async def _run_wechat_message(uid: UUID, text: str) -> str: - """微信入站一条消息:解析/建用户常驻「微信」task → 抢 run 锁 → _run_agent_bg → 取回复。""" + async def _run_wechat_message(uid: UUID, text: str, attachments=None) -> str: + """微信入站一条消息:解析/建用户常驻「微信」task → 落盘附件 → 抢 run 锁 → _run_agent_bg → 取回复。 + + attachments:已下载解密的入站附件(core.wechat.ilink.InboundAttachment,att.data 已回填)。 + 图片落盘后拼 `[用户上传的参考图] ` 行(agent 见之自调 look_at_image),文件拼 + `[用户上传的文件] ` 行(agent 用 Read/Shell 处理)。 + """ from core.agent_builder import resolve_workspace, working_dir_from_name from core.storage.utils import ensure_local_task_row from core.wechat import service as _wx + from core.wechat.ilink import attachment_basename from core.wechat.inbound import extract_last_assistant_text snap = await asyncio.to_thread(_wx.get_binding, uid) @@ -888,6 +894,32 @@ def create_app() -> FastAPI: ) await asyncio.to_thread(_wx.set_chat_task, uid, tid) + # 落盘入站附件到 /inbound/,拼 [用户上传的...] 行进 text(复用 web 端粘贴图约定) + if attachments: + from datetime import datetime + from pathlib import Path + + with session_scope() as s: + wd_db = s.execute( + select(Task.working_dir).where(Task.task_id == tid) + ).scalar_one() + inbound_dir = from_db_path(wd_db) / "inbound" + inbound_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + lines: list[str] = [] + for i, att in enumerate(attachments): + if not att.data: + continue + base = attachment_basename(att) + name = f"{ts}-{i}-{base}" + (inbound_dir / name).write_bytes(att.data) + rel = f"inbound/{name}" + tag = "[用户上传的参考图]" if att.kind == "image" else "[用户上传的文件]" + lines.append(f"{tag} {rel}") + if lines: + extra = "\n".join(lines) + text = f"{text}\n\n{extra}" if text.strip() else extra + # 抢 run 锁:正忙 → 提示稍候(同用户串行,inbound loop 本就串行) with session_scope() as s: row = s.execute(