From eed2eab0147ff78a063cf5a25e5af6c8e675ac17 Mon Sep 17 00:00:00 2001 From: 6vision Date: Tue, 16 Jun 2026 17:23:31 +0800 Subject: [PATCH] refactor(wecom_bot): use wecom_bot_mode field and clean up temp images Replace the boolean wecom_bot_callback with a wecom_bot_mode field ("websocket" | "webhook"), consistent with the Feishu channel's feishu_event_mode. Update startup() and send() to branch on the mode. Also fix a temp-file leak in _load_image_base64: downloads, format conversions and compressions wrote to /tmp but were never removed. Track only the temp files created here and delete them in a finally block, leaving the caller's original local file untouched. Co-authored-by: Cursor --- channel/wecom_bot/wecom_bot_channel.py | 98 +++++++++++++++----------- config.py | 10 +-- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py index bc3b7695..9ecbbefe 100644 --- a/channel/wecom_bot/wecom_bot_channel.py +++ b/channel/wecom_bot/wecom_bot_channel.py @@ -103,8 +103,8 @@ class WecomBotChannel(ChatChannel): self._pending_lock = threading.Lock() self._stream_states = {} # req_id -> {"stream_id": str, "content": str} - # Callback (webhook) mode state - self.callback_mode = False + # Transport mode: "websocket" (long connection) or "webhook" (HTTP callback) + self.mode = "websocket" self._crypt = None self._http_server = None # stream_id -> {"committed", "current", "finished", "images", "last_access"} @@ -119,8 +119,8 @@ class WecomBotChannel(ChatChannel): # ------------------------------------------------------------------ def startup(self): - self.callback_mode = bool(conf().get("wecom_bot_callback", False)) - if self.callback_mode: + self.mode = conf().get("wecom_bot_mode", "websocket") + if self.mode == "webhook": self._startup_callback() return @@ -878,54 +878,68 @@ class WecomBotChannel(ChatChannel): if local_path.startswith("file://"): local_path = local_path[7:] - if local_path.startswith(("http://", "https://")): - try: - resp = requests.get(local_path, timeout=30) - resp.raise_for_status() - tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}" - with open(tmp_path, "wb") as f: - f.write(resp.content) - local_path = tmp_path - except Exception as e: - logger.error(f"[WecomBot] Failed to download image for callback reply: {e}") + # Temp files we create here (downloads/conversions/compressions) must be + # cleaned up afterwards; the caller's original local file must not be. + temp_files = [] + try: + if local_path.startswith(("http://", "https://")): + try: + resp = requests.get(local_path, timeout=30) + resp.raise_for_status() + tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}" + with open(tmp_path, "wb") as f: + f.write(resp.content) + temp_files.append(tmp_path) + local_path = tmp_path + except Exception as e: + logger.error(f"[WecomBot] Failed to download image for callback reply: {e}") + return None + + if not os.path.exists(local_path): + logger.error(f"[WecomBot] Image file not found: {local_path}") return None - if not os.path.exists(local_path): - logger.error(f"[WecomBot] Image file not found: {local_path}") - return None + formatted = self._ensure_image_format(local_path) + if not formatted: + return None + if formatted != local_path: + temp_files.append(formatted) + local_path = formatted - local_path = self._ensure_image_format(local_path) - if not local_path: - return None + # Unlike the long-connection path (which uploads and sends only a tiny + # media_id), the callback reply embeds the whole image as base64 inside + # an AES-encrypted body that is returned on EVERY poll. Empirically a + # ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the + # finish packet and poll forever, so cap well below that. + callback_max_size = 512 * 1024 + if os.path.getsize(local_path) > callback_max_size: + compressed = self._compress_image(local_path, callback_max_size) + if compressed: + temp_files.append(compressed) + local_path = compressed + else: + logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)") - # Unlike the long-connection path (which uploads and sends only a tiny - # media_id), the callback reply embeds the whole image as base64 inside - # an AES-encrypted body that is returned on EVERY poll. Empirically a - # ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the - # finish packet and poll forever, so cap well below that. - callback_max_size = 512 * 1024 - if os.path.getsize(local_path) > callback_max_size: - compressed = self._compress_image(local_path, callback_max_size) - if compressed: - local_path = compressed - else: - logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)") - - try: - with open(local_path, "rb") as f: - raw = f.read() - logger.debug(f"[WecomBot] callback image base64 ready: raw={len(raw)} bytes") - return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest() - except Exception as e: - logger.error(f"[WecomBot] Failed to read image for callback reply: {e}") - return None + try: + with open(local_path, "rb") as f: + raw = f.read() + return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest() + except Exception as e: + logger.error(f"[WecomBot] Failed to read image for callback reply: {e}") + return None + finally: + for path in temp_files: + try: + os.remove(path) + except OSError: + pass # ------------------------------------------------------------------ # Send reply # ------------------------------------------------------------------ def send(self, reply: Reply, context: Context): - if self.callback_mode: + if self.mode == "webhook": self._callback_send(reply, context) return diff --git a/config.py b/config.py index c6c67bf5..b7c276d4 100644 --- a/config.py +++ b/config.py @@ -180,11 +180,11 @@ available_setting = { # WeCom smart bot config (long connection mode) "wecom_bot_id": "", # WeCom smart bot BotID "wecom_bot_secret": "", # WeCom smart bot long-connection secret - # WeCom smart bot callback (webhook) mode; default off, keep using the long connection - "wecom_bot_callback": False, # whether to receive messages via HTTP callback (webhook) instead of the long connection - "wecom_bot_token": "", # callback mode: Token configured on the bot's receive-message URL - "wecom_bot_encoding_aes_key": "", # callback mode: EncodingAESKey configured on the bot's receive-message URL - "wecom_bot_port": 9892, # callback mode: local HTTP server port for the receive-message URL + # WeCom smart bot transport mode: "websocket" (long connection) or "webhook" (HTTP callback) + "wecom_bot_mode": "websocket", + "wecom_bot_token": "", # webhook mode: Token configured on the bot's receive-message URL + "wecom_bot_encoding_aes_key": "", # webhook mode: EncodingAESKey configured on the bot's receive-message URL + "wecom_bot_port": 9892, # webhook mode: local HTTP server port for the receive-message URL # Telegram config "telegram_token": "", # Bot token from @BotFather "telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)