fix(WeiXin): fix full_url download error

This commit is contained in:
xcosmosbox 2026-03-31 12:55:29 +08:00 committed by Xubin Ren
parent 1bcd5f9742
commit 2a6c616080
2 changed files with 126 additions and 79 deletions

View File

@ -197,8 +197,7 @@ class WeixinChannel(BaseChannel):
if base_url: if base_url:
self.config.base_url = base_url self.config.base_url = base_url
return bool(self._token) return bool(self._token)
except Exception as e: except Exception:
logger.warning("Failed to load WeChat state: {}", e)
return False return False
def _save_state(self) -> None: def _save_state(self) -> None:
@ -211,8 +210,8 @@ class WeixinChannel(BaseChannel):
"base_url": self.config.base_url, "base_url": self.config.base_url,
} }
state_file.write_text(json.dumps(data, ensure_ascii=False)) state_file.write_text(json.dumps(data, ensure_ascii=False))
except Exception as e: except Exception:
logger.warning("Failed to save WeChat state: {}", e) pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# HTTP helpers (matches api.ts buildHeaders / apiFetch) # HTTP helpers (matches api.ts buildHeaders / apiFetch)
@ -243,6 +242,15 @@ class WeixinChannel(BaseChannel):
headers["SKRouteTag"] = str(self.config.route_tag).strip() headers["SKRouteTag"] = str(self.config.route_tag).strip()
return headers return headers
@staticmethod
def _is_retryable_media_download_error(err: Exception) -> bool:
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
return True
if isinstance(err, httpx.HTTPStatusError):
status_code = err.response.status_code if err.response is not None else 0
return status_code >= 500
return False
async def _api_get( async def _api_get(
self, self,
endpoint: str, endpoint: str,
@ -315,13 +323,11 @@ class WeixinChannel(BaseChannel):
async def _qr_login(self) -> bool: async def _qr_login(self) -> bool:
"""Perform QR code login flow. Returns True on success.""" """Perform QR code login flow. Returns True on success."""
try: try:
logger.info("Starting WeChat QR code login...")
refresh_count = 0 refresh_count = 0
qrcode_id, scan_url = await self._fetch_qr_code() qrcode_id, scan_url = await self._fetch_qr_code()
self._print_qr_code(scan_url) self._print_qr_code(scan_url)
current_poll_base_url = self.config.base_url current_poll_base_url = self.config.base_url
logger.info("Waiting for QR code scan...")
while self._running: while self._running:
try: try:
status_data = await self._api_get_with_base( status_data = await self._api_get_with_base(
@ -332,13 +338,11 @@ class WeixinChannel(BaseChannel):
) )
except Exception as e: except Exception as e:
if self._is_retryable_qr_poll_error(e): if self._is_retryable_qr_poll_error(e):
logger.warning("QR polling temporary error, will retry: {}", e)
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
raise raise
if not isinstance(status_data, dict): if not isinstance(status_data, dict):
logger.warning("QR polling got non-object response, continue waiting")
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
@ -362,8 +366,6 @@ class WeixinChannel(BaseChannel):
else: else:
logger.error("Login confirmed but no bot_token in response") logger.error("Login confirmed but no bot_token in response")
return False return False
elif status == "scaned":
logger.info("QR code scanned, waiting for confirmation...")
elif status == "scaned_but_redirect": elif status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip() redirect_host = str(status_data.get("redirect_host", "") or "").strip()
if redirect_host: if redirect_host:
@ -372,15 +374,7 @@ class WeixinChannel(BaseChannel):
else: else:
redirected_base = f"https://{redirect_host}" redirected_base = f"https://{redirect_host}"
if redirected_base != current_poll_base_url: if redirected_base != current_poll_base_url:
logger.info(
"QR status redirect: switching polling host to {}",
redirected_base,
)
current_poll_base_url = redirected_base current_poll_base_url = redirected_base
else:
logger.warning(
"QR status returned scaned_but_redirect but redirect_host is missing",
)
elif status == "expired": elif status == "expired":
refresh_count += 1 refresh_count += 1
if refresh_count > MAX_QR_REFRESH_COUNT: if refresh_count > MAX_QR_REFRESH_COUNT:
@ -390,14 +384,8 @@ class WeixinChannel(BaseChannel):
MAX_QR_REFRESH_COUNT, MAX_QR_REFRESH_COUNT,
) )
return False return False
logger.warning(
"QR code expired, refreshing... ({}/{})",
refresh_count,
MAX_QR_REFRESH_COUNT,
)
qrcode_id, scan_url = await self._fetch_qr_code() qrcode_id, scan_url = await self._fetch_qr_code()
self._print_qr_code(scan_url) self._print_qr_code(scan_url)
logger.info("New QR code generated, waiting for scan...")
continue continue
# status == "wait" — keep polling # status == "wait" — keep polling
@ -428,7 +416,6 @@ class WeixinChannel(BaseChannel):
qr.make(fit=True) qr.make(fit=True)
qr.print_ascii(invert=True) qr.print_ascii(invert=True)
except ImportError: except ImportError:
logger.info("QR code URL (install 'qrcode' for terminal display): {}", url)
print(f"\nLogin URL: {url}\n") print(f"\nLogin URL: {url}\n")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -490,12 +477,6 @@ class WeixinChannel(BaseChannel):
if not self._running: if not self._running:
break break
consecutive_failures += 1 consecutive_failures += 1
logger.error(
"WeChat poll error ({}/{}): {}",
consecutive_failures,
MAX_CONSECUTIVE_FAILURES,
e,
)
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
consecutive_failures = 0 consecutive_failures = 0
await asyncio.sleep(BACKOFF_DELAY_S) await asyncio.sleep(BACKOFF_DELAY_S)
@ -510,8 +491,6 @@ class WeixinChannel(BaseChannel):
await self._client.aclose() await self._client.aclose()
self._client = None self._client = None
self._save_state() self._save_state()
logger.info("WeChat channel stopped")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Polling (matches monitor.ts monitorWeixinProvider) # Polling (matches monitor.ts monitorWeixinProvider)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -537,10 +516,6 @@ class WeixinChannel(BaseChannel):
async def _poll_once(self) -> None: async def _poll_once(self) -> None:
remaining = self._session_pause_remaining_s() remaining = self._session_pause_remaining_s()
if remaining > 0: if remaining > 0:
logger.warning(
"WeChat session paused, waiting {} min before next poll.",
max((remaining + 59) // 60, 1),
)
await asyncio.sleep(remaining) await asyncio.sleep(remaining)
return return
@ -590,8 +565,8 @@ class WeixinChannel(BaseChannel):
for msg in msgs: for msg in msgs:
try: try:
await self._process_message(msg) await self._process_message(msg)
except Exception as e: except Exception:
logger.error("Error processing WeChat message: {}", e) pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Inbound message processing (matches inbound.ts + process-message.ts) # Inbound message processing (matches inbound.ts + process-message.ts)
@ -770,13 +745,6 @@ class WeixinChannel(BaseChannel):
if not content: if not content:
return return
logger.info(
"WeChat inbound: from={} items={} bodyLen={}",
from_user_id,
",".join(str(i.get("type", 0)) for i in item_list),
len(content),
)
await self._handle_message( await self._handle_message(
sender_id=from_user_id, sender_id=from_user_id,
chat_id=from_user_id, chat_id=from_user_id,
@ -821,27 +789,47 @@ class WeixinChannel(BaseChannel):
# Reference protocol behavior: VOICE/FILE/VIDEO require aes_key; # Reference protocol behavior: VOICE/FILE/VIDEO require aes_key;
# only IMAGE may be downloaded as plain bytes when key is missing. # only IMAGE may be downloaded as plain bytes when key is missing.
if media_type != "image" and not aes_key_b64: if media_type != "image" and not aes_key_b64:
logger.debug("Missing AES key for {} item, skip media download", media_type)
return None return None
# Prefer server-provided full_url, fallback to encrypted_query_param URL construction. assert self._client is not None
if full_url: fallback_url = ""
cdn_url = full_url if encrypt_query_param:
else: fallback_url = (
cdn_url = (
f"{self.config.cdn_base_url}/download" f"{self.config.cdn_base_url}/download"
f"?encrypted_query_param={quote(encrypt_query_param)}" f"?encrypted_query_param={quote(encrypt_query_param)}"
) )
assert self._client is not None download_candidates: list[tuple[str, str]] = []
resp = await self._client.get(cdn_url) if full_url:
resp.raise_for_status() download_candidates.append(("full_url", full_url))
data = resp.content if fallback_url and (not full_url or fallback_url != full_url):
download_candidates.append(("encrypt_query_param", fallback_url))
data = b""
for idx, (download_source, cdn_url) in enumerate(download_candidates):
try:
resp = await self._client.get(cdn_url)
resp.raise_for_status()
data = resp.content
break
except Exception as e:
has_more_candidates = idx + 1 < len(download_candidates)
should_fallback = (
download_source == "full_url"
and has_more_candidates
and self._is_retryable_media_download_error(e)
)
if should_fallback:
logger.warning(
"WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
media_type,
e,
)
continue
raise
if aes_key_b64 and data: if aes_key_b64 and data:
data = _decrypt_aes_ecb(data, aes_key_b64) data = _decrypt_aes_ecb(data, aes_key_b64)
elif not aes_key_b64:
logger.debug("No AES key for {} item, using raw bytes", media_type)
if not data: if not data:
return None return None
@ -856,7 +844,6 @@ class WeixinChannel(BaseChannel):
safe_name = os.path.basename(filename) safe_name = os.path.basename(filename)
file_path = media_dir / safe_name file_path = media_dir / safe_name
file_path.write_bytes(data) file_path.write_bytes(data)
logger.debug("Downloaded WeChat {} to {}", media_type, file_path)
return str(file_path) return str(file_path)
except Exception as e: except Exception as e:
@ -918,14 +905,17 @@ class WeixinChannel(BaseChannel):
await self._api_post("ilink/bot/sendtyping", body) await self._api_post("ilink/bot/sendtyping", body)
async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None: async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None:
while not stop_event.is_set(): try:
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) while not stop_event.is_set():
if stop_event.is_set(): await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
break if stop_event.is_set():
try: break
await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) try:
except Exception as e: await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING)
logger.debug("WeChat sendtyping(keepalive) failed for {}: {}", user_id, e) except Exception:
pass
finally:
pass
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
if not self._client or not self._token: if not self._client or not self._token:
@ -933,8 +923,7 @@ class WeixinChannel(BaseChannel):
return return
try: try:
self._assert_session_active() self._assert_session_active()
except RuntimeError as e: except RuntimeError:
logger.warning("WeChat send blocked: {}", e)
return return
content = msg.content.strip() content = msg.content.strip()
@ -949,15 +938,14 @@ class WeixinChannel(BaseChannel):
typing_ticket = "" typing_ticket = ""
try: try:
typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token)
except Exception as e: except Exception:
logger.warning("WeChat getconfig failed for {}: {}", msg.chat_id, e)
typing_ticket = "" typing_ticket = ""
if typing_ticket: if typing_ticket:
try: try:
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
except Exception as e: except Exception:
logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) pass
typing_keepalive_stop = asyncio.Event() typing_keepalive_stop = asyncio.Event()
typing_keepalive_task: asyncio.Task | None = None typing_keepalive_task: asyncio.Task | None = None
@ -1001,8 +989,8 @@ class WeixinChannel(BaseChannel):
if typing_ticket: if typing_ticket:
try: try:
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
except Exception as e: except Exception:
logger.debug("WeChat sendtyping(cancel) failed for {}: {}", msg.chat_id, e) pass
async def _send_text( async def _send_text(
self, self,
@ -1108,7 +1096,6 @@ class WeixinChannel(BaseChannel):
assert self._client is not None assert self._client is not None
upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body) upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body)
logger.debug("WeChat getuploadurl response: {}", upload_resp)
upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip() upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip()
upload_param = str(upload_resp.get("upload_param", "") or "") upload_param = str(upload_resp.get("upload_param", "") or "")
@ -1130,7 +1117,6 @@ class WeixinChannel(BaseChannel):
f"?encrypted_query_param={quote(upload_param)}" f"?encrypted_query_param={quote(upload_param)}"
f"&filekey={quote(file_key)}" f"&filekey={quote(file_key)}"
) )
logger.debug("WeChat CDN POST url={} ciphertextSize={}", cdn_upload_url[:80], len(encrypted_data))
cdn_resp = await self._client.post( cdn_resp = await self._client.post(
cdn_upload_url, cdn_upload_url,
@ -1146,7 +1132,6 @@ class WeixinChannel(BaseChannel):
"CDN upload response missing x-encrypted-param header; " "CDN upload response missing x-encrypted-param header; "
f"status={cdn_resp.status_code} headers={dict(cdn_resp.headers)}" f"status={cdn_resp.status_code} headers={dict(cdn_resp.headers)}"
) )
logger.debug("WeChat CDN upload success for {}, got download_param", p.name)
# Step 3: Send message with the media item # Step 3: Send message with the media item
# aes_key for CDNMedia is the hex key encoded as base64 # aes_key for CDNMedia is the hex key encoded as base64
@ -1195,7 +1180,6 @@ class WeixinChannel(BaseChannel):
raise RuntimeError( raise RuntimeError(
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}" f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
) )
logger.info("WeChat media sent: {} (type={})", p.name, item_key)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -766,6 +766,21 @@ class _DummyDownloadResponse:
return None return None
class _DummyErrorDownloadResponse(_DummyDownloadResponse):
def __init__(self, url: str, status_code: int) -> None:
super().__init__(content=b"", status_code=status_code)
self._url = url
def raise_for_status(self) -> None:
request = httpx.Request("GET", self._url)
response = httpx.Response(self.status_code, request=request)
raise httpx.HTTPStatusError(
f"download failed with status {self.status_code}",
request=request,
response=response,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None:
channel, _bus = _make_channel() channel, _bus = _make_channel()
@ -789,6 +804,37 @@ async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None:
channel._client.get.assert_awaited_once_with(full_url) channel._client.get.assert_awaited_once_with(full_url)
@pytest.mark.asyncio
async def test_download_media_item_falls_back_when_full_url_returns_retryable_error(tmp_path) -> None:
channel, _bus = _make_channel()
weixin_mod.get_media_dir = lambda _name: tmp_path
full_url = "https://cdn.example.test/download/full?taskid=123"
channel._client = SimpleNamespace(
get=AsyncMock(
side_effect=[
_DummyErrorDownloadResponse(full_url, 500),
_DummyDownloadResponse(content=b"fallback-bytes"),
]
)
)
item = {
"media": {
"full_url": full_url,
"encrypt_query_param": "enc-fallback",
},
}
saved_path = await channel._download_media_item(item, "image")
assert saved_path is not None
assert Path(saved_path).read_bytes() == b"fallback-bytes"
assert channel._client.get.await_count == 2
assert channel._client.get.await_args_list[0].args[0] == full_url
fallback_url = channel._client.get.await_args_list[1].args[0]
assert fallback_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None: async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None:
channel, _bus = _make_channel() channel, _bus = _make_channel()
@ -807,6 +853,23 @@ async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -
assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback")
@pytest.mark.asyncio
async def test_download_media_item_does_not_retry_when_full_url_fails_without_fallback(tmp_path) -> None:
channel, _bus = _make_channel()
weixin_mod.get_media_dir = lambda _name: tmp_path
full_url = "https://cdn.example.test/download/full"
channel._client = SimpleNamespace(
get=AsyncMock(return_value=_DummyErrorDownloadResponse(full_url, 500))
)
item = {"media": {"full_url": full_url}}
saved_path = await channel._download_media_item(item, "image")
assert saved_path is None
channel._client.get.assert_awaited_once_with(full_url)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None: async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None:
channel, _bus = _make_channel() channel, _bus = _make_channel()