fix(wecom_bot): defer text finish for image race; align callback image cap to 2MB

Defer the callback stream finish after a text reply so a trailing
image-with-caption send (text first, image 0.3s later) can merge in
instead of closing the stream prematurely. Raise the callback inline
image cap from 512KB to 2MB to match the long-connection upload path.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6vision
2026-06-15 18:56:20 +08:00
parent dec31dfd75
commit 630373b1f0

View File

@@ -258,6 +258,7 @@ class WecomBotChannel(ChatChannel):
"finished": False, "finished": False,
"images": [], # list of (base64_str, md5_str), flushed only at finish "images": [], # list of (base64_str, md5_str), flushed only at finish
"image_urls": [], # public http(s) image urls (usable in response_url markdown) "image_urls": [], # public http(s) image urls (usable in response_url markdown)
"image_pending": False, # an image reply is being prepared; don't finish on text yet
"last_access": now, "last_access": now,
"created_at": now, "created_at": now,
"response_url": response_url or "", "response_url": response_url or "",
@@ -752,6 +753,7 @@ class WecomBotChannel(ChatChannel):
"finished": False, "finished": False,
"images": [], "images": [],
"image_urls": [], "image_urls": [],
"image_pending": False,
"last_access": now, "last_access": now,
"created_at": now, "created_at": now,
"response_url": "", "response_url": "",
@@ -770,11 +772,31 @@ class WecomBotChannel(ChatChannel):
else: else:
state["committed"] = accumulated if accumulated else (content or "") state["committed"] = accumulated if accumulated else (content or "")
state["current"] = "" state["current"] = ""
state["finished"] = True
state["last_access"] = time.time() state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id) # Don't finish synchronously: chat_channel splits an image-with-caption
# reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
# text finished the stream immediately, WeCom would close it before the
# image arrives. Defer the finish so a trailing image can merge in.
self._schedule_text_finish(stream_id)
def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
def _run():
time.sleep(delay)
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state or state["finished"] or state.get("image_pending"):
return # already finished, or an image reply is on its way
state["finished"] = True
state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id)
threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str): def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
# Mark the image as pending up front (before the slow load/compress) so a
# preceding text finalize won't close the stream while we work.
with self._callback_lock:
self._callback_get_or_create_state(stream_id)["image_pending"] = True
b64md5 = self._load_image_base64(img_path_or_url) b64md5 = self._load_image_base64(img_path_or_url)
with self._callback_lock: with self._callback_lock:
state = self._callback_get_or_create_state(stream_id) state = self._callback_get_or_create_state(stream_id)
@@ -790,6 +812,7 @@ class WecomBotChannel(ChatChannel):
else: else:
state["committed"] = accumulated or "[图片发送失败]" state["committed"] = accumulated or "[图片发送失败]"
state["finished"] = True state["finished"] = True
state["image_pending"] = False
state["last_access"] = time.time() state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id) self._schedule_response_url_fallback(stream_id)
@@ -869,12 +892,9 @@ class WecomBotChannel(ChatChannel):
if not local_path: if not local_path:
return None return None
# The passive stream reply embeds the image as base64 and is AES-encrypted # Keep consistent with the long-connection upload path (2MB limit):
# and returned on EVERY poll. A multi-MB body gets rejected / times out on # only compress when the image exceeds the cap, otherwise send as-is.
# WeCom's side (the bubble then spins forever), so keep it small: base64 callback_max_size = 2 * 1024 * 1024
# inflates ~1.33x and the encrypted response a bit more, so target a few
# hundred KB rather than the 10MB the protocol nominally allows.
callback_max_size = 512 * 1024
if os.path.getsize(local_path) > callback_max_size: if os.path.getsize(local_path) > callback_max_size:
compressed = self._compress_image(local_path, callback_max_size) compressed = self._compress_image(local_path, callback_max_size)
if compressed: if compressed: