Compare commits
No commits in common. "b5cfce72b598131b80f6c4ee1e8512b7a98443e7" and "6f7e32bb33429d6d017ad23058e15a11f8af08db" have entirely different histories.
b5cfce72b5
...
6f7e32bb33
|
|
@ -706,7 +706,6 @@ 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>`。
|
||||
- **取码/绑定**:`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=<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 判成败,以实投为准)。
|
||||
- **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 简报可原生直推为可打开附件**,无须退下载链接。
|
||||
|
|
@ -716,7 +715,6 @@ create index on usage_events (model_profile, created_at);
|
|||
- **入站长轮询管理器**(lifespan 起,仿 §8.4 `_disk_scanner` plain-asyncio):每个 active binding 一条 `getupdates`(hold ≤35s 循环续)。收到消息 → 按 `bot_token`→binding→zcbot `user_id` 定位是谁 → **刷新该 binding 的 `latest_context_token` + 时间戳** → 映射到该用户的微信对话 task(默认一条 persistent「微信」task 保连续性,§8.5 会话模式)→ 复用 `_run_agent_bg` 跑 → 结果按 ~1000 字分块 `sendmessage`(每块新 `client_id`、中间 `state=1` 末 `state=2`)带 `context_token` 回。**无 5s ACK 约束**,长 run 天然 OK——相对企业微信回调的根本简化。
|
||||
- **出站主动推送**(scheduler 简报 / 任务结果 / `WechatPushTool`):用库里该用户 `latest_context_token`,**距上次入站 <~24h** 则直接 `sendmessage`(文本 + docx/pdf 文件直推);**超期 / 从未开口** → 推不出,退邮件兜底(§8.5)或挂起待用户下次开口刷新 token。即"用户开口过、且近 24h 活跃 → 可主动推"。
|
||||
- **scale**:N 个 active binding = N 条长轮询;公测期 N 小可接受;放大时视 1:1/1:N 实测结果改为单循环轮询多 token。
|
||||
- **web↔微信同步不对称 → web 端只读镜像(2026-06-24 取舍)**:这条 persistent「微信」task 是 web 与微信共享的同一条 DB 消息流,但写入方向不对称——**微信→web 同步**(入站经 `_poll_binding` 落库,web 打开即见),**web→微信不同步**(web 端发消息走通用 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知)。**不做双向打通**:回微信需 `context_token`、只能从入站拿且 24h 过期,双向同步会被该窗口拖成"有时同步"(不可预测)+ 两入口并发写同一上下文歧义。改为 web 端对 channel=wechat 的 task **只读镜像**(`applyChannelComposerLock` 置 readOnly + 引导去微信),交互权威单一锚定微信;主控台想主动往微信推 → 走 `WechatPushTool`/定时简报(出站语义,非对话)。
|
||||
|
||||
**接入面(复用现有范式)**:
|
||||
1. `tools/wechat_bot.py`:ClawBot 客户端(`get_bot_qrcode/get_qrcode_status/getupdates/sendmessage` + AES 媒体)+ `wechat_bot_enabled()`(开关在才挂工具,沿用 §3.4)+ `resolve_wechat_target(user_id)`→`bot_token` + `WechatPushTool`(agent 可调,按当前 run 的 user_id 解析)。HTTP 走已有 httpx。
|
||||
|
|
|
|||
14
PROGRESS.md
14
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1)
|
||||
最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,18 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 2026-06-24 / 微信 task 在 web 端只读镜像(bump 0.25.1)
|
||||
- 问题:web 端打开 channel=wechat 的常驻 task 能正常发消息,但 web→微信**单向不同步**(web 发消息走 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。
|
||||
- 取舍:不做"双向打通"(受微信 24h `context_token` 窗口约束 → 只能"有时同步",不可预测 + 两入口并发写歧义),改为 web 端**只读镜像**(单一交互权威锚定微信;想主动推走 `wechat_push`/定时简报)。
|
||||
- `web/static/js/chat.js`:`applyChannelComposerLock(meta)`(selectTask 后调)对 wechat task 置 `chat-input` readOnly + 改 placeholder「请在微信里对话」+ 禁润色;`sendMessage` 入口加 channel 守卫(Enter 兜底)。`dev.html` 加 `.readonly-locked` 置灰样式。
|
||||
|
||||
### 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)
|
||||
|
||||
- 起因: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 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.25.1"
|
||||
__version__ = "0.24.4"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import hashlib
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
|
|
@ -80,46 +80,6 @@ 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
|
||||
|
|
@ -170,30 +130,12 @@ 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:
|
||||
|
|
@ -218,62 +160,18 @@ class ILinkClient:
|
|||
d = r.json()
|
||||
msgs: list[InboundMessage] = []
|
||||
for m in d.get("msgs", []) 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),
|
||||
))
|
||||
text = "".join(
|
||||
(it.get("text_item", {}) or {}).get("text", "")
|
||||
for it in m.get("item_list", []) or []
|
||||
)
|
||||
msgs.append(InboundMessage(
|
||||
from_user_id=m.get("from_user_id", ""),
|
||||
context_token=m.get("context_token", ""),
|
||||
text="".join(text_parts),
|
||||
text=text,
|
||||
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=<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(
|
||||
self, to_user_id: str, context_token: str, item: dict, *, state: int
|
||||
|
|
@ -391,18 +289,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -21,12 +21,11 @@ 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, InboundAttachment
|
||||
from core.wechat.ilink import ILinkClient
|
||||
from core.wechat.service import BindingSnapshot
|
||||
|
||||
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)。
|
||||
# 第三参 attachments:已下载解密(att.data 已回填)的入站附件,app.py 负责落盘 + 拼提示行。
|
||||
HandleMessage = Callable[[UUID, str, list[InboundAttachment]], Awaitable[str]]
|
||||
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)
|
||||
HandleMessage = Callable[[UUID, str], Awaitable[str]]
|
||||
|
||||
|
||||
def _content_to_text(content: Any) -> str:
|
||||
|
|
@ -80,25 +79,15 @@ async def _poll_binding(
|
|||
for m in msgs:
|
||||
if stop.is_set():
|
||||
break
|
||||
# 下载入站附件(图片/文件):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:
|
||||
if not m.text.strip():
|
||||
continue
|
||||
# ① 刷新该用户推送窗口(主动推靠它续命)
|
||||
await asyncio.to_thread(
|
||||
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
|
||||
)
|
||||
# ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行)
|
||||
# ② 跑 agent 取回复
|
||||
try:
|
||||
reply = await handle_message(snap.user_id, m.text, atts)
|
||||
reply = await handle_message(snap.user_id, m.text)
|
||||
except Exception as e: # noqa: BLE001
|
||||
reply = f"[出错] {type(e).__name__}: {e}"
|
||||
# ③ 用本轮新鲜 token 分块回
|
||||
|
|
|
|||
36
web/app.py
36
web/app.py
|
|
@ -856,17 +856,11 @@ def create_app() -> FastAPI:
|
|||
wechat_stop = asyncio.Event()
|
||||
wechat_task = None
|
||||
|
||||
async def _run_wechat_message(uid: UUID, text: str, attachments=None) -> str:
|
||||
"""微信入站一条消息:解析/建用户常驻「微信」task → 落盘附件 → 抢 run 锁 → _run_agent_bg → 取回复。
|
||||
|
||||
attachments:已下载解密的入站附件(core.wechat.ilink.InboundAttachment,att.data 已回填)。
|
||||
图片落盘后拼 `[用户上传的参考图] <rel>` 行(agent 见之自调 look_at_image),文件拼
|
||||
`[用户上传的文件] <rel>` 行(agent 用 Read/Shell 处理)。
|
||||
"""
|
||||
async def _run_wechat_message(uid: UUID, text: str) -> str:
|
||||
"""微信入站一条消息:解析/建用户常驻「微信」task → 抢 run 锁 → _run_agent_bg → 取回复。"""
|
||||
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)
|
||||
|
|
@ -894,32 +888,6 @@ def create_app() -> FastAPI:
|
|||
)
|
||||
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 本就串行)
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
|
|
|
|||
|
|
@ -824,8 +824,6 @@
|
|||
}
|
||||
#chat-form .row { display: flex; gap: 8px; }
|
||||
#chat-form textarea { flex: 1; }
|
||||
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
|
||||
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
|
||||
#chat-form .hint { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* ───── files ───── */
|
||||
|
|
|
|||
|
|
@ -261,7 +261,6 @@ export async function selectTask(tid) {
|
|||
]);
|
||||
state.taskMeta = meta;
|
||||
renderChatMeta();
|
||||
applyChannelComposerLock(meta);
|
||||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||||
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
|
||||
} else {
|
||||
|
|
@ -973,26 +972,6 @@ async function deletePastedFile(rel, wrap) {
|
|||
}
|
||||
}
|
||||
|
||||
// 微信渠道 task 在 web 端只读:这条对话的唯一交互入口锚定在微信本身 —— agent 回复必须
|
||||
// 带 context_token 才能发回微信,而 token 只能从用户微信入站消息拿(24h 过期),zcbot 在
|
||||
// 协议层就没有对微信无条件说话的能力。故 web→微信单向不同步(web 发的微信侧看不到);
|
||||
// 与其做受 24h 窗口拖累的"有时同步"双向打通,不如让 web 端做干净的只读镜像(单一交互
|
||||
// 权威 + 可预测),想主动往微信推走 wechat_push / 定时简报。微信→web 仍同步(同一条 task)。
|
||||
function applyChannelComposerLock(meta) {
|
||||
const input = $("chat-input");
|
||||
if (!input) return;
|
||||
const isWechat = !!meta && meta.channel === "wechat";
|
||||
input.readOnly = isWechat;
|
||||
input.classList.toggle("readonly-locked", isWechat);
|
||||
input.placeholder = isWechat
|
||||
? "微信对话请在微信里进行 — web 端为只读镜像,可查看历史"
|
||||
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)";
|
||||
if (isWechat) {
|
||||
const opt = $("chat-optimize");
|
||||
if (opt) opt.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
|
||||
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
|
||||
// 不与主对话 run 互斥,各跑各的 LLM)。
|
||||
|
|
@ -1114,11 +1093,6 @@ function takePastedRels() {
|
|||
async function sendMessage(overrideText) {
|
||||
if (!state.taskId) return;
|
||||
if (isCurrentTaskStreaming()) return;
|
||||
// 微信渠道 task 只读:web 发的消息推不到微信(单向),挡在入口避免不一致(见 applyChannelComposerLock)
|
||||
if (state.taskMeta && state.taskMeta.channel === "wechat") {
|
||||
$("chat-hint").textContent = "微信对话请在微信里进行 — web 端为只读镜像";
|
||||
return;
|
||||
}
|
||||
const fromInput = typeof overrideText !== "string";
|
||||
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
||||
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
|
||||
|
|
|
|||
Loading…
Reference in New Issue