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 <cursoragent@cursor.com>
This commit is contained in:
6vision
2026-06-16 17:23:31 +08:00
parent 52209217fc
commit eed2eab014
2 changed files with 61 additions and 47 deletions

View File

@@ -103,8 +103,8 @@ class WecomBotChannel(ChatChannel):
self._pending_lock = threading.Lock() self._pending_lock = threading.Lock()
self._stream_states = {} # req_id -> {"stream_id": str, "content": str} self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
# Callback (webhook) mode state # Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
self.callback_mode = False self.mode = "websocket"
self._crypt = None self._crypt = None
self._http_server = None self._http_server = None
# stream_id -> {"committed", "current", "finished", "images", "last_access"} # stream_id -> {"committed", "current", "finished", "images", "last_access"}
@@ -119,8 +119,8 @@ class WecomBotChannel(ChatChannel):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def startup(self): def startup(self):
self.callback_mode = bool(conf().get("wecom_bot_callback", False)) self.mode = conf().get("wecom_bot_mode", "websocket")
if self.callback_mode: if self.mode == "webhook":
self._startup_callback() self._startup_callback()
return return
@@ -878,54 +878,68 @@ class WecomBotChannel(ChatChannel):
if local_path.startswith("file://"): if local_path.startswith("file://"):
local_path = local_path[7:] local_path = local_path[7:]
if local_path.startswith(("http://", "https://")): # Temp files we create here (downloads/conversions/compressions) must be
try: # cleaned up afterwards; the caller's original local file must not be.
resp = requests.get(local_path, timeout=30) temp_files = []
resp.raise_for_status() try:
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}" if local_path.startswith(("http://", "https://")):
with open(tmp_path, "wb") as f: try:
f.write(resp.content) resp = requests.get(local_path, timeout=30)
local_path = tmp_path resp.raise_for_status()
except Exception as e: tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}") 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 return None
if not os.path.exists(local_path): formatted = self._ensure_image_format(local_path)
logger.error(f"[WecomBot] Image file not found: {local_path}") if not formatted:
return None return None
if formatted != local_path:
temp_files.append(formatted)
local_path = formatted
local_path = self._ensure_image_format(local_path) # Unlike the long-connection path (which uploads and sends only a tiny
if not local_path: # media_id), the callback reply embeds the whole image as base64 inside
return None # 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 try:
# media_id), the callback reply embeds the whole image as base64 inside with open(local_path, "rb") as f:
# an AES-encrypted body that is returned on EVERY poll. Empirically a raw = f.read()
# ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
# finish packet and poll forever, so cap well below that. except Exception as e:
callback_max_size = 512 * 1024 logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
if os.path.getsize(local_path) > callback_max_size: return None
compressed = self._compress_image(local_path, callback_max_size) finally:
if compressed: for path in temp_files:
local_path = compressed try:
else: os.remove(path)
logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)") except OSError:
pass
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
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Send reply # Send reply
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def send(self, reply: Reply, context: Context): def send(self, reply: Reply, context: Context):
if self.callback_mode: if self.mode == "webhook":
self._callback_send(reply, context) self._callback_send(reply, context)
return return

View File

@@ -180,11 +180,11 @@ available_setting = {
# WeCom smart bot config (long connection mode) # WeCom smart bot config (long connection mode)
"wecom_bot_id": "", # WeCom smart bot BotID "wecom_bot_id": "", # WeCom smart bot BotID
"wecom_bot_secret": "", # WeCom smart bot long-connection secret "wecom_bot_secret": "", # WeCom smart bot long-connection secret
# WeCom smart bot callback (webhook) mode; default off, keep using the long connection # WeCom smart bot transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
"wecom_bot_callback": False, # whether to receive messages via HTTP callback (webhook) instead of the long connection "wecom_bot_mode": "websocket",
"wecom_bot_token": "", # callback mode: Token configured on the bot's receive-message URL "wecom_bot_token": "", # webhook 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_encoding_aes_key": "", # webhook 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_bot_port": 9892, # webhook mode: local HTTP server port for the receive-message URL
# Telegram config # Telegram config
"telegram_token": "", # Bot token from @BotFather "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) "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)