mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 12:47:25 +08:00
fix(wecom_bot): rescue late replies via response_url + fix degrade notice
Handle agent replies that finish after WeCom stops polling the passive stream (the poll window is ~6min from the user's message). - Capture response_url from the message callback and, when a reply is finalized but no poll picks it up within a short grace period, push it as a one-shot active markdown reply (valid 1h, single use) - Guard against double delivery via delivered/url_sent flags - Embed public image URLs in the active markdown; note when a local image can't be delivered post-timeout - Append (instead of discarding) the unsupported-type notice for file/voice/video replies so streamed text is preserved - Quiet the per-poll debug log and log stream completion with content size Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -247,16 +247,22 @@ class WecomBotChannel(ChatChannel):
|
|||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
server.stop()
|
server.stop()
|
||||||
|
|
||||||
def _new_callback_stream(self) -> str:
|
def _new_callback_stream(self, response_url: str = "") -> str:
|
||||||
"""Create a new stream state and return its id."""
|
"""Create a new stream state and return its id."""
|
||||||
stream_id = uuid.uuid4().hex[:16]
|
stream_id = uuid.uuid4().hex[:16]
|
||||||
|
now = time.time()
|
||||||
with self._callback_lock:
|
with self._callback_lock:
|
||||||
self._callback_streams[stream_id] = {
|
self._callback_streams[stream_id] = {
|
||||||
"committed": "",
|
"committed": "",
|
||||||
"current": "",
|
"current": "",
|
||||||
"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
|
||||||
"last_access": time.time(),
|
"image_urls": [], # public http(s) image urls (usable in response_url markdown)
|
||||||
|
"last_access": now,
|
||||||
|
"created_at": now,
|
||||||
|
"response_url": response_url or "",
|
||||||
|
"delivered": False, # final answer handed to WeCom via a poll
|
||||||
|
"url_sent": False, # final answer pushed via response_url (active reply)
|
||||||
}
|
}
|
||||||
return stream_id
|
return stream_id
|
||||||
|
|
||||||
@@ -284,7 +290,10 @@ class WecomBotChannel(ChatChannel):
|
|||||||
return None
|
return None
|
||||||
context, wecom_msg = result
|
context, wecom_msg = result
|
||||||
|
|
||||||
stream_id = self._new_callback_stream()
|
# response_url lets us actively reply once within 1h, used as a fallback
|
||||||
|
# when the agent finishes after WeCom stops polling (max ~6min window).
|
||||||
|
response_url = data.get("response_url", "") or ""
|
||||||
|
stream_id = self._new_callback_stream(response_url=response_url)
|
||||||
wecom_msg.stream_id = stream_id
|
wecom_msg.stream_id = stream_id
|
||||||
context["wecom_stream_id"] = stream_id
|
context["wecom_stream_id"] = stream_id
|
||||||
context["on_event"] = self._make_callback_stream_callback(stream_id)
|
context["on_event"] = self._make_callback_stream_callback(stream_id)
|
||||||
@@ -305,9 +314,15 @@ class WecomBotChannel(ChatChannel):
|
|||||||
# Unknown / expired stream: tell WeCom we're done to stop polling.
|
# Unknown / expired stream: tell WeCom we're done to stop polling.
|
||||||
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||||
state["last_access"] = time.time()
|
state["last_access"] = time.time()
|
||||||
content = state["committed"] + state["current"]
|
|
||||||
finished = state["finished"]
|
finished = state["finished"]
|
||||||
|
if state.get("url_sent"):
|
||||||
|
# Final answer already pushed via response_url; finish silently.
|
||||||
|
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||||
|
content = state["committed"] + state["current"]
|
||||||
images = state["images"] if finished else []
|
images = state["images"] if finished else []
|
||||||
|
if finished:
|
||||||
|
state["delivered"] = True
|
||||||
|
logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")
|
||||||
|
|
||||||
stream = {"id": stream_id, "finish": finished, "content": content}
|
stream = {"id": stream_id, "finish": finished, "content": content}
|
||||||
if images:
|
if images:
|
||||||
@@ -698,37 +713,50 @@ class WecomBotChannel(ChatChannel):
|
|||||||
self._callback_finalize_image(stream_id, reply.content)
|
self._callback_finalize_image(stream_id, reply.content)
|
||||||
elif reply.type == ReplyType.FILE:
|
elif reply.type == ReplyType.FILE:
|
||||||
# Passive callback replies only support text + image (base64); files
|
# Passive callback replies only support text + image (base64); files
|
||||||
# are not supported by the protocol, so degrade to a text notice.
|
# are not supported by the protocol, so append a notice to whatever
|
||||||
|
# text the agent already streamed (do not drop it).
|
||||||
text = getattr(reply, "text_content", "") or ""
|
text = getattr(reply, "text_content", "") or ""
|
||||||
note = (text + "\n" if text else "") + "[文件回复在回调模式下暂不支持]"
|
note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)"
|
||||||
self._callback_finalize_text(stream_id, note)
|
self._callback_finalize_text(stream_id, note, append=True)
|
||||||
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
|
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
|
||||||
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
|
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
|
||||||
self._callback_finalize_text(stream_id, "[该消息类型在回调模式下暂不支持]")
|
text = getattr(reply, "text_content", "") or ""
|
||||||
|
note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)"
|
||||||
|
self._callback_finalize_text(stream_id, note, append=True)
|
||||||
else:
|
else:
|
||||||
self._callback_finalize_text(stream_id, str(reply.content))
|
self._callback_finalize_text(stream_id, str(reply.content))
|
||||||
|
|
||||||
def _callback_get_or_create_state(self, stream_id: str) -> dict:
|
def _callback_get_or_create_state(self, stream_id: str) -> dict:
|
||||||
state = self._callback_streams.get(stream_id)
|
state = self._callback_streams.get(stream_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
|
now = time.time()
|
||||||
state = {
|
state = {
|
||||||
"committed": "",
|
"committed": "",
|
||||||
"current": "",
|
"current": "",
|
||||||
"finished": False,
|
"finished": False,
|
||||||
"images": [],
|
"images": [],
|
||||||
"last_access": time.time(),
|
"image_urls": [],
|
||||||
|
"last_access": now,
|
||||||
|
"created_at": now,
|
||||||
|
"response_url": "",
|
||||||
|
"delivered": False,
|
||||||
|
"url_sent": False,
|
||||||
}
|
}
|
||||||
self._callback_streams[stream_id] = state
|
self._callback_streams[stream_id] = state
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _callback_finalize_text(self, stream_id: str, content: str):
|
def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
|
||||||
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)
|
||||||
accumulated = (state["committed"] + state["current"]).strip()
|
accumulated = (state["committed"] + state["current"]).strip()
|
||||||
state["committed"] = accumulated if accumulated else (content or "")
|
if append and accumulated:
|
||||||
|
state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
|
||||||
|
else:
|
||||||
|
state["committed"] = accumulated if accumulated else (content or "")
|
||||||
state["current"] = ""
|
state["current"] = ""
|
||||||
state["finished"] = True
|
state["finished"] = True
|
||||||
state["last_access"] = time.time()
|
state["last_access"] = time.time()
|
||||||
|
self._schedule_response_url_fallback(stream_id)
|
||||||
|
|
||||||
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):
|
||||||
b64md5 = self._load_image_base64(img_path_or_url)
|
b64md5 = self._load_image_base64(img_path_or_url)
|
||||||
@@ -739,10 +767,65 @@ class WecomBotChannel(ChatChannel):
|
|||||||
if b64md5:
|
if b64md5:
|
||||||
state["images"].append(b64md5)
|
state["images"].append(b64md5)
|
||||||
state["committed"] = accumulated
|
state["committed"] = accumulated
|
||||||
|
# Remember the public url (if any) so the response_url fallback
|
||||||
|
# can embed it as markdown when the poll window has closed.
|
||||||
|
if img_path_or_url.startswith(("http://", "https://")):
|
||||||
|
state["image_urls"].append(img_path_or_url)
|
||||||
else:
|
else:
|
||||||
state["committed"] = accumulated or "[图片发送失败]"
|
state["committed"] = accumulated or "[图片发送失败]"
|
||||||
state["finished"] = True
|
state["finished"] = True
|
||||||
state["last_access"] = time.time()
|
state["last_access"] = time.time()
|
||||||
|
self._schedule_response_url_fallback(stream_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Active reply fallback (response_url): rescue replies that finish after
|
||||||
|
# WeCom stops polling (the passive stream window is ~6 min from the user's
|
||||||
|
# message). A short delay lets an in-flight poll deliver first; only if no
|
||||||
|
# poll picks up the finished answer do we push it actively via response_url.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
|
||||||
|
def _run():
|
||||||
|
time.sleep(delay)
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
if state.get("delivered") or state.get("url_sent"):
|
||||||
|
return # a poll already delivered (or fallback already ran)
|
||||||
|
response_url = state.get("response_url") or ""
|
||||||
|
if not response_url:
|
||||||
|
logger.warning(
|
||||||
|
f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
content = (state["committed"] + state["current"]).strip()
|
||||||
|
image_urls = list(state.get("image_urls") or [])
|
||||||
|
has_images = bool(state.get("images"))
|
||||||
|
state["url_sent"] = True
|
||||||
|
|
||||||
|
self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()
|
||||||
|
|
||||||
|
def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
|
||||||
|
"""Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
|
||||||
|
md = content or ""
|
||||||
|
if image_urls:
|
||||||
|
md += ("\n\n" if md else "") + "\n".join(f"" for u in image_urls)
|
||||||
|
elif has_images:
|
||||||
|
md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)"
|
||||||
|
if not md:
|
||||||
|
md = "(处理完成)"
|
||||||
|
payload = {"msgtype": "markdown", "markdown": {"content": md}}
|
||||||
|
try:
|
||||||
|
resp = requests.post(response_url, json=payload, timeout=15)
|
||||||
|
logger.info(
|
||||||
|
f"[WecomBot] response_url active reply sent for {stream_id}: "
|
||||||
|
f"status={resp.status_code}, body={resp.text[:200]}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")
|
||||||
|
|
||||||
def _load_image_base64(self, img_path_or_url: str):
|
def _load_image_base64(self, img_path_or_url: str):
|
||||||
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
|
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
|
||||||
@@ -1254,7 +1337,10 @@ class WecomBotCallbackController:
|
|||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
msgtype = data.get("msgtype", "")
|
msgtype = data.get("msgtype", "")
|
||||||
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
|
# Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
|
||||||
|
# callbacks here (poll completion is logged in the stream-poll handler).
|
||||||
|
if msgtype != "stream":
|
||||||
|
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if msgtype == "stream":
|
if msgtype == "stream":
|
||||||
|
|||||||
Reference in New Issue
Block a user