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:
caoqianming 2026-06-24 16:15:52 +08:00
parent 6f7e32bb33
commit 529d7f1046
6 changed files with 181 additions and 16 deletions

View File

@ -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 简报可原生直推为可打开附件**,无须退下载链接。

View File

@ -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 列并列)最差。

View File

@ -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"

View File

@ -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:

View File

@ -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 分块回

View File

@ -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(