From c605b0b08044211822d5eeb67fb23b73ac08d024 Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 18:11:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat=5Fkf):=20cache=20images/files=20and?= =?UTF-8?q?=20merge=20into=20next=20text=20turn=20Adopt=20the=20same=20cha?= =?UTF-8?q?nnel-level=20pattern=20as=20weixin/wecom=5Fbot/feishu=20so=20th?= =?UTF-8?q?e=20agent=20actually=20sees=20attachments=20the=20user=20sent:?= =?UTF-8?q?=20-=20IMAGE:=20agent=20mode=20never=20reads=20memory.USER=5FIM?= =?UTF-8?q?AGE=5FCACHE,=20so=20a=20photo=20=20=20sent=20before=20a=20quest?= =?UTF-8?q?ion=20(e.g.=20"image"=20then=2030s=20later=20"what's=20this=3F"?= =?UTF-8?q?)=20=20=20used=20to=20be=20lost.=20Now=20lone=20images=20go=20i?= =?UTF-8?q?nto=20channel.file=5Fcache=20and=20=20=20the=20next=20TEXT=20tu?= =?UTF-8?q?rn=20appends=20"[=E5=9B=BE=E7=89=87:=20]"=20to=20the=20qu?= =?UTF-8?q?ery=20before=20=20=20producing=20the=20context.=20Cross-batch?= =?UTF-8?q?=20image+text=20combinations=20now=20=20=20work=20as=20users=20?= =?UTF-8?q?expect.=20-=20FILE:=20previously=20dropped=20at=20the=20sync=5F?= =?UTF-8?q?msg=20filter=20and=20unsupported=20=20=20by=20WechatKfMessage.?= =?UTF-8?q?=20Add=20msgtype=3D"file"=20parsing,=20download=20via=20the=20?= =?UTF-8?q?=20=20WeCom=20media=20API,=20preserve=20the=20original=20filena?= =?UTF-8?q?me=20from=20=20=20Content-Disposition=20(RFC=205987=20+=20plain?= =?UTF-8?q?=20forms),=20and=20route=20through=20=20=20the=20same=20file=5F?= =?UTF-8?q?cache=20pipeline=20as=20images,=20surfacing=20as=20=20=20"[?= =?UTF-8?q?=E6=96=87=E4=BB=B6:=20]"=20in=20the=20next=20text=20turn.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- channel/wechat_kf/wechat_kf_channel.py | 41 ++++++++++++++++++++++++-- channel/wechat_kf/wechat_kf_message.py | 40 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index e33662db..c613d92f 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -30,9 +30,10 @@ from wechatpy.enterprise.crypto import WeChatCrypto from wechatpy.enterprise.exceptions import InvalidCorpIdException from wechatpy.exceptions import InvalidSignatureException, WeChatClientException -from bridge.context import Context +from bridge.context import Context, ContextType from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel +from channel.file_cache import get_file_cache from channel.wechat_kf.wechat_kf_cursor_store import CursorStore from channel.wechat_kf.wechat_kf_message import WechatKfMessage from common.log import logger @@ -314,12 +315,48 @@ class WechatKfChannel(ChatChannel): msgs = self._pull_messages(token, open_kfid, existing_cursor) if not msgs: return + file_cache = get_file_cache() for raw in msgs: try: kf_msg = WechatKfMessage(msg=raw, client=self.client) except NotImplementedError as e: logger.debug("[wechat_kf] {}".format(e)) continue + + session_id = kf_msg.from_user_id + + # Cache lone images/files and wait for the user's follow-up + # text. Agent mode never reads memory.USER_IMAGE_CACHE, so + # without this the attachment is effectively lost. + if kf_msg.ctype in (ContextType.IMAGE, ContextType.FILE): + ftype = "image" if kf_msg.ctype == ContextType.IMAGE else "file" + try: + kf_msg.prepare() # download to local tmp path + file_cache.add(session_id, kf_msg.content, file_type=ftype) + logger.info( + "[wechat_kf] {} cached for session {}: {}".format( + ftype, session_id, kf_msg.content + ) + ) + except Exception as e: + logger.warning(f"[wechat_kf] cache {ftype} failed: {e}") + continue + + # On a text turn, attach any pending images/files as references + # so the downstream agent can pick them up via the text content. + if kf_msg.ctype == ContextType.TEXT: + cached_files = file_cache.get(session_id) + if cached_files: + refs = [] + for fi in cached_files: + ftype, fpath = fi["type"], fi["path"] + if ftype == "image": + refs.append(f"[图片: {fpath}]") + else: + refs.append(f"[文件: {fpath}]") + kf_msg.content = kf_msg.content + "\n" + "\n".join(refs) + file_cache.clear(session_id) + context = self._compose_context( kf_msg.ctype, kf_msg.content, @@ -371,7 +408,7 @@ class WechatKfChannel(ChatChannel): # back into ourselves. if not item.get("external_userid"): continue - if item.get("msgtype") in ("text", "image", "voice"): + if item.get("msgtype") in ("text", "image", "voice", "file"): collected.append(item) cursor_after = data.get("next_cursor") or "" if cursor_after: diff --git a/channel/wechat_kf/wechat_kf_message.py b/channel/wechat_kf/wechat_kf_message.py index 2505dbea..0abe61ad 100644 --- a/channel/wechat_kf/wechat_kf_message.py +++ b/channel/wechat_kf/wechat_kf_message.py @@ -3,6 +3,9 @@ Adapter that turns a single `sync_msg` item from WeCom customer-service into a CoW `ChatMessage` object. """ +import os +import re + from wechatpy.enterprise import WeChatClient from bridge.context import ContextType @@ -11,6 +14,23 @@ from common.log import logger from common.tmp_dir import TmpDir +def _extract_filename(content_disposition: str) -> str: + """Best-effort parse of `filename` / `filename*` from a Content-Disposition + header. Returns '' when nothing usable is found.""" + if not content_disposition: + return "" + # RFC 5987 form: filename*=UTF-8''xxx + m = re.search(r"filename\*=(?:[^'\"]*'[^']*'\s*)?([^;]+)", content_disposition) + if m: + try: + from urllib.parse import unquote + return unquote(m.group(1).strip().strip('"')) + except Exception: + return m.group(1).strip().strip('"') + m = re.search(r'filename\s*=\s*"?([^";]+)"?', content_disposition) + return m.group(1).strip() if m else "" + + class WechatKfMessage(ChatMessage): """ msg structure (from cgi-bin/kf/sync_msg): @@ -72,6 +92,26 @@ class WechatKfMessage(ChatMessage): logger.info(f"[wechat_kf] Failed to download voice, {response.content}") self._prepare_fn = download_voice + elif self.msgtype == "file": + self.ctype = ContextType.FILE + media_id = msg.get("file", {}).get("media_id", "") + # Provisional path; rewritten in download_file() once we have + # the original filename from Content-Disposition. + self.content = TmpDir().path() + media_id + + def download_file(): + response = client.media.download(media_id) + if response.status_code == 200: + filename = _extract_filename( + response.headers.get("Content-Disposition", "") + ) or media_id + self.content = os.path.join(TmpDir().path(), filename) + with open(self.content, "wb") as f: + f.write(response.content) + else: + logger.info(f"[wechat_kf] Failed to download file, {response.content}") + + self._prepare_fn = download_file else: raise NotImplementedError( f"[wechat_kf] Unsupported message type: {self.msgtype}"