mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 05:27:59 +08:00
fix(wexin): resolve image/file send and receive failures
This commit is contained in:
@@ -172,10 +172,8 @@ class WeixinApi:
|
|||||||
|
|
||||||
def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
|
def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
|
||||||
rawsize: int, rawfilemd5: str, filesize: int,
|
rawsize: int, rawfilemd5: str, filesize: int,
|
||||||
aeskey: str,
|
aeskey: str) -> dict:
|
||||||
thumb_rawsize: int = 0, thumb_rawfilemd5: str = "",
|
return self._post("ilink/bot/getuploadurl", {
|
||||||
thumb_filesize: int = 0) -> dict:
|
|
||||||
body = {
|
|
||||||
"filekey": filekey,
|
"filekey": filekey,
|
||||||
"media_type": media_type,
|
"media_type": media_type,
|
||||||
"to_user_id": to_user_id,
|
"to_user_id": to_user_id,
|
||||||
@@ -183,14 +181,8 @@ class WeixinApi:
|
|||||||
"rawfilemd5": rawfilemd5,
|
"rawfilemd5": rawfilemd5,
|
||||||
"filesize": filesize,
|
"filesize": filesize,
|
||||||
"aeskey": aeskey,
|
"aeskey": aeskey,
|
||||||
}
|
"no_need_thumb": True,
|
||||||
if thumb_rawsize > 0:
|
})
|
||||||
body["thumb_rawsize"] = thumb_rawsize
|
|
||||||
body["thumb_rawfilemd5"] = thumb_rawfilemd5
|
|
||||||
body["thumb_filesize"] = thumb_filesize
|
|
||||||
else:
|
|
||||||
body["no_need_thumb"] = True
|
|
||||||
return self._post("ilink/bot/getuploadurl", body)
|
|
||||||
|
|
||||||
# ── getConfig / sendTyping ─────────────────────────────────────────
|
# ── getConfig / sendTyping ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -259,10 +251,18 @@ def _md5_bytes(data: bytes) -> str:
|
|||||||
return hashlib.md5(data).hexdigest()
|
return hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_ecb_padded_size(plaintext_size: int) -> int:
|
||||||
|
"""PKCS7 padded size for AES-128-ECB."""
|
||||||
|
return ((plaintext_size + 1 + 15) // 16) * 16
|
||||||
|
|
||||||
|
|
||||||
|
UPLOAD_MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
||||||
media_type: int) -> dict:
|
media_type: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Upload a local file to the Weixin CDN.
|
Upload a local file to the Weixin CDN (matching official plugin protocol).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api: WeixinApi instance
|
api: WeixinApi instance
|
||||||
@@ -275,35 +275,14 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
|||||||
"""
|
"""
|
||||||
aes_key = os.urandom(16)
|
aes_key = os.urandom(16)
|
||||||
aes_key_hex = aes_key.hex()
|
aes_key_hex = aes_key.hex()
|
||||||
|
filekey = uuid.uuid4().hex
|
||||||
|
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
raw_data = f.read()
|
raw_data = f.read()
|
||||||
|
|
||||||
raw_size = len(raw_data)
|
raw_size = len(raw_data)
|
||||||
raw_md5 = _md5_bytes(raw_data)
|
raw_md5 = _md5_bytes(raw_data)
|
||||||
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
|
cipher_size = _aes_ecb_padded_size(raw_size)
|
||||||
cipher_size = len(encrypted)
|
|
||||||
filekey = uuid.uuid4().hex
|
|
||||||
|
|
||||||
thumb_rawsize = 0
|
|
||||||
thumb_rawfilemd5 = ""
|
|
||||||
thumb_filesize = 0
|
|
||||||
|
|
||||||
if media_type == 1: # IMAGE - generate a tiny thumbnail
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
img = Image.open(file_path)
|
|
||||||
img.thumbnail((100, 100))
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format="JPEG", quality=60)
|
|
||||||
thumb_raw = buf.getvalue()
|
|
||||||
thumb_rawsize = len(thumb_raw)
|
|
||||||
thumb_rawfilemd5 = _md5_bytes(thumb_raw)
|
|
||||||
thumb_encrypted = _aes_ecb_encrypt(thumb_raw, aes_key)
|
|
||||||
thumb_filesize = len(thumb_encrypted)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[Weixin] Thumbnail generation failed, skipping: {e}")
|
|
||||||
|
|
||||||
resp = api.get_upload_url(
|
resp = api.get_upload_url(
|
||||||
filekey=filekey,
|
filekey=filekey,
|
||||||
@@ -313,37 +292,52 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
|||||||
rawfilemd5=raw_md5,
|
rawfilemd5=raw_md5,
|
||||||
filesize=cipher_size,
|
filesize=cipher_size,
|
||||||
aeskey=aes_key_hex,
|
aeskey=aes_key_hex,
|
||||||
thumb_rawsize=thumb_rawsize,
|
|
||||||
thumb_rawfilemd5=thumb_rawfilemd5,
|
|
||||||
thumb_filesize=thumb_filesize,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
upload_param = resp.get("upload_param", "")
|
upload_param = resp.get("upload_param", "")
|
||||||
if not upload_param:
|
if not upload_param:
|
||||||
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
|
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
|
||||||
|
|
||||||
cdn_url = api.cdn_base_url + "?" + upload_param
|
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
|
||||||
put_resp = requests.put(cdn_url, data=encrypted, headers={
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Length": str(cipher_size),
|
|
||||||
}, timeout=60)
|
|
||||||
put_resp.raise_for_status()
|
|
||||||
|
|
||||||
# Upload thumbnail if we have one
|
from urllib.parse import quote
|
||||||
thumb_upload_param = resp.get("thumb_upload_param", "")
|
cdn_url = (f"{api.cdn_base_url}/upload"
|
||||||
if thumb_upload_param and thumb_filesize > 0:
|
f"?encrypted_query_param={quote(upload_param)}"
|
||||||
thumb_cdn_url = api.cdn_base_url + "?" + thumb_upload_param
|
f"&filekey={quote(filekey)}")
|
||||||
|
|
||||||
|
download_param = None
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
requests.put(thumb_cdn_url, data=thumb_encrypted, headers={
|
cdn_resp = requests.post(cdn_url, data=encrypted, headers={
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
"Content-Length": str(thumb_filesize),
|
}, timeout=120)
|
||||||
}, timeout=30)
|
if 400 <= cdn_resp.status_code < 500:
|
||||||
|
err_msg = cdn_resp.headers.get("x-error-message", cdn_resp.text[:200])
|
||||||
|
raise RuntimeError(f"CDN client error {cdn_resp.status_code}: {err_msg}")
|
||||||
|
cdn_resp.raise_for_status()
|
||||||
|
download_param = cdn_resp.headers.get("x-encrypted-param", "")
|
||||||
|
if not download_param:
|
||||||
|
raise RuntimeError("CDN response missing x-encrypted-param header")
|
||||||
|
logger.debug(f"[Weixin] CDN upload success attempt={attempt} filekey={filekey}")
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Weixin] Thumbnail upload failed (non-fatal): {e}")
|
last_error = e
|
||||||
|
if "client error" in str(e):
|
||||||
|
raise
|
||||||
|
if attempt < UPLOAD_MAX_RETRIES:
|
||||||
|
logger.warning(f"[Weixin] CDN upload attempt {attempt} failed, retrying: {e}")
|
||||||
|
else:
|
||||||
|
logger.error(f"[Weixin] CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}")
|
||||||
|
|
||||||
|
if not download_param:
|
||||||
|
raise last_error or RuntimeError("CDN upload failed")
|
||||||
|
|
||||||
|
aes_key_b64 = base64.b64encode(aes_key_hex.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"encrypt_query_param": upload_param,
|
"encrypt_query_param": download_param,
|
||||||
"aes_key_b64": base64.b64encode(aes_key).decode("utf-8"),
|
"aes_key_b64": aes_key_b64,
|
||||||
"ciphertext_size": cipher_size,
|
"ciphertext_size": cipher_size,
|
||||||
"raw_size": raw_size,
|
"raw_size": raw_size,
|
||||||
}
|
}
|
||||||
@@ -363,19 +357,30 @@ def download_media_from_cdn(cdn_base_url: str, encrypt_query_param: str,
|
|||||||
Returns:
|
Returns:
|
||||||
save_path on success
|
save_path on success
|
||||||
"""
|
"""
|
||||||
url = cdn_base_url + "?" + encrypt_query_param
|
from urllib.parse import quote
|
||||||
|
url = f"{cdn_base_url}/download?encrypted_query_param={quote(encrypt_query_param)}"
|
||||||
resp = requests.get(url, timeout=60)
|
resp = requests.get(url, timeout=60)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
# Determine key format (hex string or base64)
|
# Determine key format:
|
||||||
|
# 1) 32-char hex string → 16 raw bytes
|
||||||
|
# 2) base64 string → decode → if 32 bytes, treat as hex-encoded → 16 raw bytes
|
||||||
|
# 3) base64 string → decode → 16 raw bytes directly
|
||||||
try:
|
try:
|
||||||
key_bytes = bytes.fromhex(aes_key)
|
key_bytes = bytes.fromhex(aes_key)
|
||||||
if len(key_bytes) != 16:
|
if len(key_bytes) != 16:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
key_bytes = base64.b64decode(aes_key)
|
decoded = base64.b64decode(aes_key)
|
||||||
if len(key_bytes) != 16:
|
if len(decoded) == 32:
|
||||||
raise ValueError(f"Invalid AES key length: {len(key_bytes)}")
|
try:
|
||||||
|
key_bytes = bytes.fromhex(decoded.decode("ascii"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
raise ValueError(f"Invalid AES key: 32 bytes but not valid hex")
|
||||||
|
elif len(decoded) == 16:
|
||||||
|
key_bytes = decoded
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid AES key length after base64 decode: {len(decoded)}")
|
||||||
|
|
||||||
decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
|
decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user