mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-02 09:22:36 +00:00
fix(WeiXin): fix full_url download error
This commit is contained in:
parent
1bcd5f9742
commit
2a6c616080
@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user