From 19b075e02ac13b9b2d4cfe439bd6671180583d14 Mon Sep 17 00:00:00 2001 From: Long Sizhuo Date: Sat, 11 Apr 2026 11:56:23 +0800 Subject: [PATCH 1/4] fix(gateway/weixin): correct aes_key encoding and upload_full_url CDN path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in the outbound media pipeline that caused images sent by the bot to appear as a grey placeholder in the WeChat client: 1. aes_key encoding in image_item (root cause of grey box) The WeChat client expects aes_key as base64(hex_string_bytes), not base64(raw_bytes). Its decode chain is: base64_decode → 32 ASCII hex chars → fromhex() → 16-byte AES key Sending base64(raw_bytes) yielded 16 decoded bytes that the client couldn't interpret as hex, resulting in the wrong key and failed AES-128-ECB decryption. Before: base64.b64encode(aes_key) After: base64.b64encode(aes_key.hex().encode("ascii")) 2. upload_full_url CDN path used PUT instead of POST Newer iLink API instances return upload_full_url instead of upload_param. The CDN endpoint behind upload_full_url returns 404 on PUT; POST succeeds. Fixed by trying POST first and falling back to PUT on 404. 3. Wrong encrypted_query_param source for upload_full_url path The encrypted_query_param embedded in upload_full_url is an upload-auth token, not the download key. The correct download key is the x-encrypted-param header returned by the CDN after a successful upload. Also fix send_image_file() parameter name: the method was called with image_path= but the parameter was declared as path=, causing a TypeError. Also fix run_agent.py to read max_tokens from custom_providers per-model config, so models configured under custom_providers can set their own output token limit. Fixes #7529 --- gateway/platforms/weixin.py | 38 ++++++++++++++++++++++++++----------- run_agent.py | 10 +++++++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 42b0b7fffe83d..e23f32fb72b59 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1425,11 +1425,14 @@ async def send_image( async def send_image_file( self, chat_id: str, - path: str, + image_path: str = "", caption: str = "", reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + **kwargs, ) -> SendResult: + # Accept legacy 'path' kwarg for backwards compat + path = image_path or kwargs.get("path", "") return await self.send_document(chat_id, path, caption=caption, metadata=metadata) async def send_document( @@ -1495,26 +1498,39 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: ) elif upload_full_url: timeout = aiohttp.ClientTimeout(total=120) - async with self._session.put( - upload_full_url, - data=ciphertext, - headers={"Content-Type": "application/octet-stream"}, - timeout=timeout, - ) as response: - response.raise_for_status() - encrypted_query_param = response.headers.get("x-encrypted-param") or filekey + _upload_headers = {"Content-Type": "application/octet-stream"} + # The upload_full_url contains an upload-auth token in its own + # encrypted_query_param query param; that is NOT the download key. + # The actual download key is returned by the CDN in the + # x-encrypted-param response header after a successful upload. + encrypted_query_param = None + for _method in ("post", "put"): + _req = getattr(self._session, _method) + async with _req(upload_full_url, data=ciphertext, headers=_upload_headers, timeout=timeout) as response: + if response.status == 404 and _method == "post": + await response.read() + logger.debug("[%s] CDN upload POST→404, retrying with PUT", self.name) + continue + response.raise_for_status() + encrypted_query_param = response.headers.get("x-encrypted-param") or filekey + break + if encrypted_query_param is None: + raise RuntimeError("CDN upload failed with both POST and PUT") else: raise RuntimeError(f"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}") context_token = self._token_store.get(self._account_id, chat_id) + # aes_key in the message MUST be base64(hex_string_of_key), NOT base64(raw_bytes). + # WeChat client decodes: base64 → 32 ASCII hex chars → bytes.fromhex → 16-byte AES key. + aes_key_b64_for_msg = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii") media_item = item_builder( encrypt_query_param=encrypted_query_param, - aes_key_b64=base64.b64encode(aes_key).decode("ascii"), + aes_key_b64=aes_key_b64_for_msg, + aes_key_hex=aes_key.hex(), ciphertext_size=len(ciphertext), plaintext_size=rawsize, filename=Path(path).name, ) - last_message_id = None if caption: last_message_id = f"hermes-weixin-{uuid.uuid4().hex}" diff --git a/run_agent.py b/run_agent.py index cf418a5766fe6..76072a3e1ced3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1247,7 +1247,7 @@ def __init__( # Store for reuse in switch_model (so config override persists across model switches) self._config_context_length = _config_context_length - # Check custom_providers per-model context_length + # Check custom_providers per-model context_length and max_tokens if _config_context_length is None: _custom_providers = _agent_cfg.get("custom_providers") if isinstance(_custom_providers, list): @@ -1266,6 +1266,14 @@ def __init__( _config_context_length = int(_cp_ctx) except (TypeError, ValueError): pass + # Also read max_tokens from per-model config if not already set + if self.max_tokens is None: + _cp_max_tok = _cp_model_cfg.get("max_tokens") + if _cp_max_tok is not None: + try: + self.max_tokens = int(_cp_max_tok) + except (TypeError, ValueError): + pass break self.context_compressor = ContextCompressor( From 05ecfad479632553134d57a01f96a7dde533ce98 Mon Sep 17 00:00:00 2001 From: Long Sizhuo Date: Sat, 11 Apr 2026 14:54:12 +0800 Subject: [PATCH 2/4] fix(gateway/weixin,run_agent): address PR #7531 review feedback - run_agent: decouple per-model max_tokens lookup from context_length guard so an explicit context_length no longer suppresses max_tokens - gateway/weixin: return a clear SendResult error when send_image_file is called without image_path/path instead of failing later with IsADirectoryError on Path("").read_bytes() - gateway/weixin: drop unused aes_key_hex kwarg from item_builder call (none of the media builder lambdas read it) --- gateway/platforms/weixin.py | 7 +++++-- run_agent.py | 20 +++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index e23f32fb72b59..8a4357663102f 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1431,8 +1431,12 @@ async def send_image_file( metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: - # Accept legacy 'path' kwarg for backwards compat path = image_path or kwargs.get("path", "") + if not path: + return SendResult( + success=False, + error="send_image_file requires 'image_path' (or legacy 'path')", + ) return await self.send_document(chat_id, path, caption=caption, metadata=metadata) async def send_document( @@ -1526,7 +1530,6 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: media_item = item_builder( encrypt_query_param=encrypted_query_param, aes_key_b64=aes_key_b64_for_msg, - aes_key_hex=aes_key.hex(), ciphertext_size=len(ciphertext), plaintext_size=rawsize, filename=Path(path).name, diff --git a/run_agent.py b/run_agent.py index 76072a3e1ced3..5061ccaa749bc 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1247,8 +1247,10 @@ def __init__( # Store for reuse in switch_model (so config override persists across model switches) self._config_context_length = _config_context_length - # Check custom_providers per-model context_length and max_tokens - if _config_context_length is None: + # Check custom_providers per-model context_length and max_tokens. + # Look up independently so an explicit context_length doesn't suppress + # the per-model max_tokens lookup, and vice versa. + if _config_context_length is None or self.max_tokens is None: _custom_providers = _agent_cfg.get("custom_providers") if isinstance(_custom_providers, list): for _cp_entry in _custom_providers: @@ -1260,13 +1262,13 @@ def __init__( if isinstance(_cp_models, dict): _cp_model_cfg = _cp_models.get(self.model, {}) if isinstance(_cp_model_cfg, dict): - _cp_ctx = _cp_model_cfg.get("context_length") - if _cp_ctx is not None: - try: - _config_context_length = int(_cp_ctx) - except (TypeError, ValueError): - pass - # Also read max_tokens from per-model config if not already set + if _config_context_length is None: + _cp_ctx = _cp_model_cfg.get("context_length") + if _cp_ctx is not None: + try: + _config_context_length = int(_cp_ctx) + except (TypeError, ValueError): + pass if self.max_tokens is None: _cp_max_tok = _cp_model_cfg.get("max_tokens") if _cp_max_tok is not None: From 56fc74cfb503e620fdd0658a9ff68d6bd80d69f3 Mon Sep 17 00:00:00 2001 From: Long Sizhuo Date: Sat, 11 Apr 2026 20:11:10 +0800 Subject: [PATCH 3/4] chore(weixin-pr): drop unrelated run_agent.py max_tokens change The per-model max_tokens lookup in run_agent.py is unrelated to the Weixin image fix. Restoring it to main's state so this PR stays focused per CONTRIBUTING.md ("One logical change per PR"). --- run_agent.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/run_agent.py b/run_agent.py index 5061ccaa749bc..cf418a5766fe6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1247,10 +1247,8 @@ def __init__( # Store for reuse in switch_model (so config override persists across model switches) self._config_context_length = _config_context_length - # Check custom_providers per-model context_length and max_tokens. - # Look up independently so an explicit context_length doesn't suppress - # the per-model max_tokens lookup, and vice versa. - if _config_context_length is None or self.max_tokens is None: + # Check custom_providers per-model context_length + if _config_context_length is None: _custom_providers = _agent_cfg.get("custom_providers") if isinstance(_custom_providers, list): for _cp_entry in _custom_providers: @@ -1262,20 +1260,12 @@ def __init__( if isinstance(_cp_models, dict): _cp_model_cfg = _cp_models.get(self.model, {}) if isinstance(_cp_model_cfg, dict): - if _config_context_length is None: - _cp_ctx = _cp_model_cfg.get("context_length") - if _cp_ctx is not None: - try: - _config_context_length = int(_cp_ctx) - except (TypeError, ValueError): - pass - if self.max_tokens is None: - _cp_max_tok = _cp_model_cfg.get("max_tokens") - if _cp_max_tok is not None: - try: - self.max_tokens = int(_cp_max_tok) - except (TypeError, ValueError): - pass + _cp_ctx = _cp_model_cfg.get("context_length") + if _cp_ctx is not None: + try: + _config_context_length = int(_cp_ctx) + except (TypeError, ValueError): + pass break self.context_compressor = ContextCompressor( From ee3f32d18b0f61e426e4820ab4a75fae0d3b6e13 Mon Sep 17 00:00:00 2001 From: Long Sizhuo Date: Sun, 12 Apr 2026 04:10:58 +0800 Subject: [PATCH 4/4] fix(gateway/weixin): align send_document signature with base class The gateway's auto-forward code at gateway/platforms/base.py:1676 calls send_document with `file_path=` kwarg, but WeixinAdapter.send_document declared the argument as `path`, so any non-image/non-video file send through the auto-forward path raised: TypeError: WeixinAdapter.send_document() got an unexpected keyword argument 'file_path' Rename the primary argument to `file_path` to match the base class (and every other platform adapter). Also accept `file_name`, `reply_to`, and trailing `**kwargs` for full base-class parity. A legacy `path=` kwarg is still honored through `**kwargs`, and empty calls now return a clear SendResult error instead of ambiguously failing inside `_send_file`. Co-authored-by: moonaries90 <53324877+moonaries90@users.noreply.github.com> --- gateway/platforms/weixin.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 8a4357663102f..abdfa7356a39d 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1442,14 +1442,23 @@ async def send_image_file( async def send_document( self, chat_id: str, - path: str, - caption: str = "", + file_path: Optional[str] = None, + caption: Optional[str] = None, + file_name: Optional[str] = None, # noqa: ARG002 — accepted for base-class parity + reply_to: Optional[str] = None, # noqa: ARG002 — accepted for base-class parity metadata: Optional[Dict[str, Any]] = None, + **kwargs, ) -> SendResult: + target = file_path or kwargs.get("path") + if not target: + return SendResult( + success=False, + error="send_document requires 'file_path' (or legacy 'path')", + ) if not self._session or not self._token: return SendResult(success=False, error="Not connected") try: - message_id = await self._send_file(chat_id, path, caption) + message_id = await self._send_file(chat_id, target, caption or "") return SendResult(success=True, message_id=message_id) except Exception as exc: logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc)