feat(wechat): 入站收图片/文件,CDN 下载+AES 解密落盘 + bump 0.25.0
get_updates 原只抽 text_item,图片/文件 item 被丢成空 text,inbound 又因空文本 continue → 用户发的图/文件静默丢弃、零落库(DB 实证)。 - ilink: InboundAttachment + 解析 image_item/file_item + download_media (CDN /c2c/download GET 密文 → AES-128-ECB 解,发送侧加密的逆);key 双 编码兜底(base64(raw16)/base64(hex32)),图片按 magic bytes 补扩展名 - inbound: handle_message 契约加附件参,文本/附件都空才跳过,下载失败 只丢该附件不拖垮整条 - app.py: 附件落盘 <wd>/inbound/,图片拼 [用户上传的参考图](走 look_at_image)、文件拼 [用户上传的文件](走 Read/Shell),复用 web 端 粘贴图约定,不碰模型链路 crypto roundtrip + 双编码 key decode 已单测;端到端(GET/POST、真实 image_item 结构)待用户重发一张图实测。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f7e32bb33
commit
529d7f1046
|
|
@ -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 <bot_token>`。
|
**协议要点(自实现客户端,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 <bot_token>`。
|
||||||
- **取码/绑定**:`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),实现要**过期自动换码**。
|
- **取码/绑定**:`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}`。
|
- **收**:`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=<media.encrypt_query_param>` 取密文 → **AES-128-ECB+PKCS7 解密**(key 优先图片 `aeskey`,否则 `media.aes_key` 两种编码兜底:base64(raw16) / base64(hex32))。落盘 `<wd>/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 判成败,以实投为准)。
|
- **发**:`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 凭据(扫码下发)。
|
- **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=<urlenc(upload_param)>&filekey=<urlenc(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 简报可原生直推为可打开附件**,无须退下载链接。
|
- **文件发送(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=<urlenc(upload_param)>&filekey=<urlenc(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 简报可原生直推为可打开附件**,无须退下载链接。
|
||||||
|
|
|
||||||
|
|
@ -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-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`:附件落盘 `<wd>/inbound/<ts>-<i>-<name>`,图片拼 `[用户上传的参考图] <rel>`(agent 自调 `look_at_image` 看图)、文件拼 `[用户上传的文件] <rel>`(agent 用 Read/Shell),**复用 web 端粘贴图同一约定**,不碰模型链路。
|
||||||
|
- 协议下载分支(GET vs POST、aes_key 取哪支)有真机实测风险:crypto roundtrip + 双编码 key decode 已单测通过;端到端待用户重发一张图验证(原图 cursor 已过)。
|
||||||
|
|
||||||
### 2026-06-24 / 微信绑定表重构:两表合一 channel_bindings(判别列+JSONB,bump 0.24.3)
|
### 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 列并列)最差。
|
- 起因:ClawBot(0012 `wechat_bot_bindings`,8 列)+ 企微(0014 `wecom_bindings`,1 列)各一表。从架构角度复盘:渠道绑定本质="用户在某渠道的一份配置",各渠道字段形态不同 → 最优是**判别列 + JSONB 多态**(与本库 `usage_events` kind+units / `scheduled_jobs.notify` 同范式),加渠道(飞书/TG…)零 migration。分表不扛增长、与库内范式不一致;单宽表(NULL 列并列)最差。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.24.4"
|
__version__ = "0.25.0"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import hashlib
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
@ -80,6 +80,46 @@ def _aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes:
|
||||||
return enc.update(padded) + enc.finalize()
|
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)───────────────────────────
|
# ─────────────────────────── 绑定(无 token)───────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -130,12 +170,30 @@ def poll_qrcode_status(
|
||||||
|
|
||||||
# ─────────────────────────── 收发(带 token)───────────────────────────
|
# ─────────────────────────── 收发(带 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
|
@dataclass
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
from_user_id: str # xxx@im.wechat
|
from_user_id: str # xxx@im.wechat
|
||||||
context_token: str # 回复 / 24h 内主动推须带回
|
context_token: str # 回复 / 24h 内主动推须带回
|
||||||
text: str
|
text: str
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
attachments: list[InboundAttachment] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ILinkClient:
|
class ILinkClient:
|
||||||
|
|
@ -160,18 +218,62 @@ class ILinkClient:
|
||||||
d = r.json()
|
d = r.json()
|
||||||
msgs: list[InboundMessage] = []
|
msgs: list[InboundMessage] = []
|
||||||
for m in d.get("msgs", []) or []:
|
for m in d.get("msgs", []) or []:
|
||||||
text = "".join(
|
text_parts: list[str] = []
|
||||||
(it.get("text_item", {}) or {}).get("text", "")
|
attachments: list[InboundAttachment] = []
|
||||||
for it in m.get("item_list", []) or []
|
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(
|
msgs.append(InboundMessage(
|
||||||
from_user_id=m.get("from_user_id", ""),
|
from_user_id=m.get("from_user_id", ""),
|
||||||
context_token=m.get("context_token", ""),
|
context_token=m.get("context_token", ""),
|
||||||
text=text,
|
text="".join(text_parts),
|
||||||
raw=m,
|
raw=m,
|
||||||
|
attachments=attachments,
|
||||||
))
|
))
|
||||||
return msgs, d.get("get_updates_buf", cursor)
|
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=<media.encrypt_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(
|
def _send(
|
||||||
self, to_user_id: str, context_token: str, item: dict, *, state: int
|
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)
|
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:
|
def _read_file_capped(file_path: str | os.PathLike) -> bytes:
|
||||||
size = os.path.getsize(file_path)
|
size = os.path.getsize(file_path)
|
||||||
if size > MAX_FILE_BYTES:
|
if size > MAX_FILE_BYTES:
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,12 @@ from sqlalchemy import select
|
||||||
from core.storage import session_scope
|
from core.storage import session_scope
|
||||||
from core.storage.models import Message
|
from core.storage.models import Message
|
||||||
from core.wechat import service
|
from core.wechat import service
|
||||||
from core.wechat.ilink import ILinkClient
|
from core.wechat.ilink import ILinkClient, InboundAttachment
|
||||||
from core.wechat.service import BindingSnapshot
|
from core.wechat.service import BindingSnapshot
|
||||||
|
|
||||||
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)
|
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)。
|
||||||
HandleMessage = Callable[[UUID, str], Awaitable[str]]
|
# 第三参 attachments:已下载解密(att.data 已回填)的入站附件,app.py 负责落盘 + 拼提示行。
|
||||||
|
HandleMessage = Callable[[UUID, str, list[InboundAttachment]], Awaitable[str]]
|
||||||
|
|
||||||
|
|
||||||
def _content_to_text(content: Any) -> str:
|
def _content_to_text(content: Any) -> str:
|
||||||
|
|
@ -79,15 +80,25 @@ async def _poll_binding(
|
||||||
for m in msgs:
|
for m in msgs:
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
break
|
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
|
continue
|
||||||
# ① 刷新该用户推送窗口(主动推靠它续命)
|
# ① 刷新该用户推送窗口(主动推靠它续命)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
|
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
|
||||||
)
|
)
|
||||||
# ② 跑 agent 取回复
|
# ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行)
|
||||||
try:
|
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
|
except Exception as e: # noqa: BLE001
|
||||||
reply = f"[出错] {type(e).__name__}: {e}"
|
reply = f"[出错] {type(e).__name__}: {e}"
|
||||||
# ③ 用本轮新鲜 token 分块回
|
# ③ 用本轮新鲜 token 分块回
|
||||||
|
|
|
||||||
36
web/app.py
36
web/app.py
|
|
@ -856,11 +856,17 @@ def create_app() -> FastAPI:
|
||||||
wechat_stop = asyncio.Event()
|
wechat_stop = asyncio.Event()
|
||||||
wechat_task = None
|
wechat_task = None
|
||||||
|
|
||||||
async def _run_wechat_message(uid: UUID, text: str) -> str:
|
async def _run_wechat_message(uid: UUID, text: str, attachments=None) -> str:
|
||||||
"""微信入站一条消息:解析/建用户常驻「微信」task → 抢 run 锁 → _run_agent_bg → 取回复。"""
|
"""微信入站一条消息:解析/建用户常驻「微信」task → 落盘附件 → 抢 run 锁 → _run_agent_bg → 取回复。
|
||||||
|
|
||||||
|
attachments:已下载解密的入站附件(core.wechat.ilink.InboundAttachment,att.data 已回填)。
|
||||||
|
图片落盘后拼 `[用户上传的参考图] <rel>` 行(agent 见之自调 look_at_image),文件拼
|
||||||
|
`[用户上传的文件] <rel>` 行(agent 用 Read/Shell 处理)。
|
||||||
|
"""
|
||||||
from core.agent_builder import resolve_workspace, working_dir_from_name
|
from core.agent_builder import resolve_workspace, working_dir_from_name
|
||||||
from core.storage.utils import ensure_local_task_row
|
from core.storage.utils import ensure_local_task_row
|
||||||
from core.wechat import service as _wx
|
from core.wechat import service as _wx
|
||||||
|
from core.wechat.ilink import attachment_basename
|
||||||
from core.wechat.inbound import extract_last_assistant_text
|
from core.wechat.inbound import extract_last_assistant_text
|
||||||
|
|
||||||
snap = await asyncio.to_thread(_wx.get_binding, uid)
|
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)
|
await asyncio.to_thread(_wx.set_chat_task, uid, tid)
|
||||||
|
|
||||||
|
# 落盘入站附件到 <wd>/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 本就串行)
|
# 抢 run 锁:正忙 → 提示稍候(同用户串行,inbound loop 本就串行)
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
row = s.execute(
|
row = s.execute(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue