Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 35 additions & 19 deletions gateway/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,17 +548,21 @@ async def _upload_ciphertext(
Accepts either a constructed CDN URL (from upload_param) or a direct
upload_full_url — both use POST with the raw ciphertext as the body.
"""
timeout = aiohttp.ClientTimeout(total=120)
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response:
if response.status == 200:
encrypted_param = response.headers.get("x-encrypted-param")
if encrypted_param:
await response.read()
return encrypted_param
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do_upload() -> str:
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}) as response:
if response.status == 200:
encrypted_param = response.headers.get("x-encrypted-param")
if encrypted_param:
await response.read()
return encrypted_param
raw = await response.text()
raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}")
raw = await response.text()
raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}")
raw = await response.text()
raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}")
raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}")
return await asyncio.wait_for(_do_upload(), timeout=120)


async def _download_bytes(
Expand All @@ -567,10 +571,13 @@ async def _download_bytes(
url: str,
timeout_seconds: float = 60.0,
) -> bytes:
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
async with session.get(url, timeout=timeout) as response:
response.raise_for_status()
return await response.read()
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors.
async def _do_download() -> bytes:
async with session.get(url) as response:
response.raise_for_status()
return await response.read()
return await asyncio.wait_for(_do_download(), timeout=timeout_seconds)


_WEIXIN_CDN_ALLOWLIST: frozenset[str] = frozenset(
Expand Down Expand Up @@ -1216,7 +1223,12 @@ async def connect(self) -> bool:
logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc)

self._poll_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector())
self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector())
# Disable aiohttp's built-in ClientTimeout (total=None) to prevent
# "Timeout context manager should be used inside a task" errors when
# send() is invoked via asyncio.run_coroutine_threadsafe() from cron.
# Timeout is managed externally via asyncio.wait_for() in _api_post/_api_get.
_no_aiohttp_timeout = aiohttp.ClientTimeout(total=None, connect=None, sock_connect=None, sock_read=None)
self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector(), timeout=_no_aiohttp_timeout)
self._token_store.restore(self._account_id)
self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll")
self._mark_connected()
Expand Down Expand Up @@ -1824,10 +1836,14 @@ async def _download_remote_media(self, url: str) -> str:
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}")

assert self._send_session is not None
async with self._send_session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response:
response.raise_for_status()
data = await response.read()
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors.
async def _do_fetch():
async with self._send_session.get(url) as response:
response.raise_for_status()
return await response.read()
data = await asyncio.wait_for(_do_fetch(), timeout=30)
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle:
handle.write(data)
return handle.name
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,7 @@
# Debug share upload-time redaction (May 2026)
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
"mrcoferland@gmail.com": "mrcoferland", # PR #19023
"chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050
}


Expand Down
4 changes: 3 additions & 1 deletion tests/gateway/test_weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,9 @@ def put(self, *_args, **_kwargs):
assert upload_url == "https://upload.example.com/media"
assert upload_kwargs["headers"] == {"Content-Type": "application/octet-stream"}
assert upload_kwargs["data"]
assert upload_kwargs["timeout"].total == 120
# Timeout is now enforced externally via asyncio.wait_for() rather than
# aiohttp.ClientTimeout, so it no longer appears as a post() kwarg.
assert "timeout" not in upload_kwargs
payload = api_post_mock.await_args.kwargs["payload"]
media = payload["msg"]["item_list"][0]["image_item"]["media"]
assert media["encrypt_query_param"] == "enc-param"
Expand Down
Loading