imchat 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imchat/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .version import __version__
imchat/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.0.0'
@@ -0,0 +1,64 @@
1
+ from .client import WeChatClient
2
+ from .types import (
3
+ WeixinMessage,
4
+ MessageItem,
5
+ TextItem,
6
+ ImageItem,
7
+ VoiceItem,
8
+ FileItem,
9
+ VideoItem,
10
+ CDNMedia,
11
+ SendMessageReq,
12
+ SendTypingReq,
13
+ GetUpdatesReq,
14
+ GetUpdatesResp,
15
+ GetUploadUrlReq,
16
+ GetUploadUrlResp,
17
+ GetConfigResp,
18
+ BaseInfo,
19
+ UploadMediaType,
20
+ MessageType,
21
+ MessageItemType,
22
+ MessageState,
23
+ TypingStatus,
24
+ )
25
+ from .auth import WeChatAuth
26
+ from .exceptions import (
27
+ WeChatError,
28
+ WeChatAPIError,
29
+ WeChatAuthError,
30
+ WeChatSessionExpired,
31
+ WeChatCDNError,
32
+ )
33
+
34
+ __version__ = "1.0.0"
35
+ __all__ = [
36
+ "WeChatClient",
37
+ "WeChatAuth",
38
+ "WeixinMessage",
39
+ "MessageItem",
40
+ "TextItem",
41
+ "ImageItem",
42
+ "VoiceItem",
43
+ "FileItem",
44
+ "VideoItem",
45
+ "CDNMedia",
46
+ "SendMessageReq",
47
+ "SendTypingReq",
48
+ "GetUpdatesReq",
49
+ "GetUpdatesResp",
50
+ "GetUploadUrlReq",
51
+ "GetUploadUrlResp",
52
+ "GetConfigResp",
53
+ "BaseInfo",
54
+ "UploadMediaType",
55
+ "MessageType",
56
+ "MessageItemType",
57
+ "MessageState",
58
+ "TypingStatus",
59
+ "WeChatError",
60
+ "WeChatAPIError",
61
+ "WeChatAuthError",
62
+ "WeChatSessionExpired",
63
+ "WeChatCDNError",
64
+ ]
imchat/wechat/api.py ADDED
@@ -0,0 +1,197 @@
1
+ import json
2
+ import base64
3
+ import secrets
4
+ from typing import Optional, Dict, Any
5
+ import httpx
6
+
7
+ from .types import (
8
+ BaseInfo,
9
+ GetUpdatesReq,
10
+ GetUpdatesResp,
11
+ GetUploadUrlReq,
12
+ GetUploadUrlResp,
13
+ SendMessageReq,
14
+ SendTypingReq,
15
+ GetConfigResp,
16
+ )
17
+ from .exceptions import WeChatAPIError, WeChatSessionExpired
18
+
19
+
20
+ DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000
21
+ DEFAULT_API_TIMEOUT_MS = 15_000
22
+ DEFAULT_CONFIG_TIMEOUT_MS = 10_000
23
+ SESSION_EXPIRED_ERRCODE = -14
24
+
25
+
26
+ class WeChatAPIClient:
27
+ """微信 ilink API 客户端"""
28
+
29
+ def __init__(
30
+ self,
31
+ base_url: str,
32
+ token: Optional[str] = None,
33
+ ilink_app_id: str = "bot",
34
+ channel_version: str = "1.0.0",
35
+ timeout_ms: int = DEFAULT_API_TIMEOUT_MS,
36
+ long_poll_timeout_ms: int = DEFAULT_LONG_POLL_TIMEOUT_MS,
37
+ ):
38
+ self.base_url = base_url.rstrip("/") + "/"
39
+ self.token = token
40
+ self.ilink_app_id = ilink_app_id
41
+ self.channel_version = channel_version
42
+ self.timeout_ms = timeout_ms
43
+ self.long_poll_timeout_ms = long_poll_timeout_ms
44
+ self._client = httpx.AsyncClient()
45
+
46
+ def _build_client_version(self) -> int:
47
+ """iLink-App-ClientVersion: uint32 encoded as 0x00MMNNPP"""
48
+ parts = self.channel_version.split(".")
49
+ major = int(parts[0]) if len(parts) > 0 else 0
50
+ minor = int(parts[1]) if len(parts) > 1 else 0
51
+ patch = int(parts[2]) if len(parts) > 2 else 0
52
+ return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF)
53
+
54
+ def _random_wechat_uin(self) -> str:
55
+ """X-WECHAT-UIN header: random uint32 -> decimal string -> base64"""
56
+ uint32 = secrets.randbits(32)
57
+ return base64.b64encode(str(uint32).encode("utf-8")).decode("utf-8")
58
+
59
+ def _build_common_headers(self) -> Dict[str, str]:
60
+ headers = {
61
+ "iLink-App-Id": self.ilink_app_id,
62
+ "iLink-App-ClientVersion": str(self._build_client_version()),
63
+ }
64
+ return headers
65
+
66
+ def _build_post_headers(self, body: str) -> Dict[str, str]:
67
+ headers = {
68
+ "Content-Type": "application/json",
69
+ "AuthorizationType": "ilink_bot_token",
70
+ "Content-Length": str(len(body.encode("utf-8"))),
71
+ "X-WECHAT-UIN": self._random_wechat_uin(),
72
+ **self._build_common_headers(),
73
+ }
74
+ if self.token and self.token.strip():
75
+ headers["Authorization"] = f"Bearer {self.token.strip()}"
76
+ return headers
77
+
78
+ def _build_base_info(self) -> Dict[str, Any]:
79
+ return {"channel_version": self.channel_version}
80
+
81
+ async def _api_get(self, endpoint: str, timeout_ms: Optional[int] = None) -> str:
82
+ """GET 请求"""
83
+ url = self.base_url + endpoint.lstrip("/")
84
+ headers = self._build_common_headers()
85
+ timeout = timeout_ms or self.timeout_ms
86
+
87
+ try:
88
+ resp = await self._client.get(url, headers=headers, timeout=timeout / 1000)
89
+ except httpx.TimeoutException:
90
+ raise WeChatAPIError(f"GET {endpoint} timeout after {timeout}ms")
91
+
92
+ text = resp.text
93
+ if resp.status_code >= 400:
94
+ raise WeChatAPIError(
95
+ f"GET {endpoint} {resp.status_code}: {text}",
96
+ status_code=resp.status_code,
97
+ response_text=text,
98
+ )
99
+ return text
100
+
101
+ async def _api_post(
102
+ self, endpoint: str, body_dict: Dict[str, Any], timeout_ms: Optional[int] = None
103
+ ) -> str:
104
+ """POST JSON 请求"""
105
+ url = self.base_url + endpoint.lstrip("/")
106
+ body = json.dumps(body_dict, ensure_ascii=False)
107
+ headers = self._build_post_headers(body)
108
+ timeout = timeout_ms or self.timeout_ms
109
+
110
+ try:
111
+ resp = await self._client.post(
112
+ url, headers=headers, content=body.encode("utf-8"), timeout=timeout / 1000
113
+ )
114
+ except httpx.TimeoutException:
115
+ raise WeChatAPIError(f"POST {endpoint} timeout after {timeout}ms")
116
+
117
+ text = resp.text
118
+ if resp.status_code >= 400:
119
+ raise WeChatAPIError(
120
+ f"POST {endpoint} {resp.status_code}: {text}",
121
+ status_code=resp.status_code,
122
+ response_text=text,
123
+ )
124
+ return text
125
+
126
+ async def get_updates(self, get_updates_buf: str = "") -> GetUpdatesResp:
127
+ """长轮询获取消息更新"""
128
+ try:
129
+ text = await self._api_post(
130
+ "ilink/bot/getupdates",
131
+ {
132
+ "get_updates_buf": get_updates_buf,
133
+ "base_info": self._build_base_info(),
134
+ },
135
+ timeout_ms=self.long_poll_timeout_ms,
136
+ )
137
+ return GetUpdatesResp.from_dict(json.loads(text))
138
+ except httpx.TimeoutException:
139
+ # 长轮询超时是正常的,返回空响应让调用方重试
140
+ return GetUpdatesResp(
141
+ ret=0, msgs=[], get_updates_buf=get_updates_buf
142
+ )
143
+
144
+ async def get_upload_url(self, req: GetUploadUrlReq) -> GetUploadUrlResp:
145
+ """获取预签名的 CDN 上传 URL"""
146
+ body = req.to_dict()
147
+ body["base_info"] = self._build_base_info()
148
+ text = await self._api_post("ilink/bot/getuploadurl", body)
149
+ return GetUploadUrlResp.from_dict(json.loads(text))
150
+
151
+ async def send_message(self, req: SendMessageReq) -> None:
152
+ """发送单条消息"""
153
+ body = req.to_dict()
154
+ if body.get("msg"):
155
+ body["msg"]["base_info"] = self._build_base_info()
156
+ await self._api_post("ilink/bot/sendmessage", body)
157
+
158
+ async def get_config(
159
+ self, ilink_user_id: str, context_token: Optional[str] = None
160
+ ) -> GetConfigResp:
161
+ """获取用户配置 (包含 typing_ticket)"""
162
+ body = {
163
+ "ilink_user_id": ilink_user_id,
164
+ "base_info": self._build_base_info(),
165
+ }
166
+ if context_token:
167
+ body["context_token"] = context_token
168
+ text = await self._api_post(
169
+ "ilink/bot/getconfig", body, timeout_ms=DEFAULT_CONFIG_TIMEOUT_MS
170
+ )
171
+ return GetConfigResp.from_dict(json.loads(text))
172
+
173
+ async def send_typing(self, req: SendTypingReq) -> None:
174
+ """发送 typing 指示器"""
175
+ body = req.to_dict()
176
+ body["base_info"] = self._build_base_info()
177
+ await self._api_post("ilink/bot/sendtyping", body, timeout_ms=DEFAULT_CONFIG_TIMEOUT_MS)
178
+
179
+ async def get_bot_qrcode(self, bot_type: str = "3") -> Dict[str, Any]:
180
+ """获取登录 QR 码"""
181
+ endpoint = f"ilink/bot/get_bot_qrcode?bot_type={bot_type}"
182
+ text = await self._api_get(endpoint)
183
+ return json.loads(text)
184
+
185
+ async def get_qrcode_status(
186
+ self, qrcode: str, timeout_ms: Optional[int] = None
187
+ ) -> Dict[str, Any]:
188
+ """轮询 QR 码状态"""
189
+ endpoint = f"ilink/bot/get_qrcode_status?qrcode={qrcode}"
190
+ try:
191
+ text = await self._api_get(endpoint, timeout_ms=timeout_ms or 35_000)
192
+ return json.loads(text)
193
+ except httpx.TimeoutException:
194
+ return {"status": "wait"}
195
+
196
+ async def close(self):
197
+ await self._client.aclose()
imchat/wechat/auth.py ADDED
@@ -0,0 +1,235 @@
1
+ import asyncio
2
+ import uuid
3
+ from typing import Optional, Dict, Any, Callable
4
+ from dataclasses import dataclass
5
+
6
+ from .api import WeChatAPIClient
7
+ from .exceptions import WeChatAuthError
8
+
9
+
10
+ DEFAULT_ILINK_BOT_TYPE = "3"
11
+ FIXED_BASE_URL = "https://ilinkai.weixin.qq.com"
12
+ ACTIVE_LOGIN_TTL_MS = 5 * 60_000
13
+ QR_LONG_POLL_TIMEOUT_MS = 35_000
14
+ MAX_QR_REFRESH_COUNT = 3
15
+
16
+
17
+ @dataclass
18
+ class QRCodeResponse:
19
+ qrcode: str
20
+ qrcode_img_content: str
21
+
22
+
23
+ @dataclass
24
+ class QRStatusResponse:
25
+ status: str # "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect"
26
+ bot_token: Optional[str] = None
27
+ ilink_bot_id: Optional[str] = None
28
+ baseurl: Optional[str] = None
29
+ ilink_user_id: Optional[str] = None
30
+ redirect_host: Optional[str] = None
31
+
32
+
33
+ @dataclass
34
+ class LoginResult:
35
+ connected: bool
36
+ bot_token: Optional[str] = None
37
+ account_id: Optional[str] = None
38
+ base_url: Optional[str] = None
39
+ user_id: Optional[str] = None
40
+ message: str = ""
41
+
42
+
43
+ class WeChatAuth:
44
+ """微信扫码认证管理器"""
45
+
46
+ def __init__(self, bot_type: str = DEFAULT_ILINK_BOT_TYPE):
47
+ self.bot_type = bot_type
48
+ self._active_logins: Dict[str, Dict[str, Any]] = {}
49
+
50
+ async def start_login(self, account_id: Optional[str] = None) -> Dict[str, str]:
51
+ """
52
+ 开始微信扫码登录
53
+ 返回: {"qrcode_url": "...", "session_key": "...", "message": "..."}
54
+ """
55
+ session_key = account_id or str(uuid.uuid4())
56
+
57
+ # 清理过期登录
58
+ now = asyncio.get_event_loop().time() * 1000
59
+ expired = [
60
+ k for k, v in self._active_logins.items()
61
+ if now - v.get("started_at", 0) > ACTIVE_LOGIN_TTL_MS
62
+ ]
63
+ for k in expired:
64
+ del self._active_logins[k]
65
+
66
+ # 检查是否有活跃的登录
67
+ existing = self._active_logins.get(session_key)
68
+ if existing and (now - existing["started_at"] < ACTIVE_LOGIN_TTL_MS):
69
+ return {
70
+ "qrcode_url": existing["qrcode_url"],
71
+ "session_key": session_key,
72
+ "message": "二维码已就绪,请使用微信扫描。",
73
+ }
74
+
75
+ client = WeChatAPIClient(FIXED_BASE_URL)
76
+ try:
77
+ resp = await client.get_bot_qrcode(self.bot_type)
78
+ qr = QRCodeResponse(
79
+ qrcode=resp["qrcode"],
80
+ qrcode_img_content=resp["qrcode_img_content"],
81
+ )
82
+ except Exception as e:
83
+ raise WeChatAuthError(f"Failed to fetch QR code: {e}")
84
+ finally:
85
+ await client.close()
86
+
87
+ self._active_logins[session_key] = {
88
+ "session_key": session_key,
89
+ "id": str(uuid.uuid4()),
90
+ "qrcode": qr.qrcode,
91
+ "qrcode_url": qr.qrcode_img_content,
92
+ "started_at": now,
93
+ "current_api_base_url": FIXED_BASE_URL,
94
+ }
95
+
96
+ return {
97
+ "qrcode_url": qr.qrcode_img_content,
98
+ "session_key": session_key,
99
+ "message": "使用微信扫描以下二维码,以完成连接。",
100
+ }
101
+
102
+ async def wait_for_login(
103
+ self,
104
+ session_key: str,
105
+ timeout_ms: int = 480_000,
106
+ verbose: bool = False,
107
+ on_status_change: Optional[Callable[[str], None]] = None,
108
+ ) -> LoginResult:
109
+ """
110
+ 等待扫码登录完成
111
+ 轮询 QR 码状态直到确认或超时
112
+ """
113
+ active_login = self._active_logins.get(session_key)
114
+ if not active_login:
115
+ return LoginResult(
116
+ connected=False,
117
+ message="当前没有进行中的登录,请先发起登录。",
118
+ )
119
+
120
+ now = asyncio.get_event_loop().time() * 1000
121
+ if now - active_login["started_at"] > ACTIVE_LOGIN_TTL_MS:
122
+ del self._active_logins[session_key]
123
+ return LoginResult(
124
+ connected=False,
125
+ message="二维码已过期,请重新生成。",
126
+ )
127
+
128
+ timeout = max(timeout_ms, 1000)
129
+ deadline = asyncio.get_event_loop().time() * 1000 + timeout
130
+ scanned_printed = False
131
+ qr_refresh_count = 1
132
+
133
+ active_login["current_api_base_url"] = FIXED_BASE_URL
134
+
135
+ while asyncio.get_event_loop().time() * 1000 < deadline:
136
+ current_base_url = active_login.get("current_api_base_url", FIXED_BASE_URL)
137
+ client = WeChatAPIClient(current_base_url)
138
+
139
+ try:
140
+ status_resp = await client.get_qrcode_status(
141
+ active_login["qrcode"], timeout_ms=QR_LONG_POLL_TIMEOUT_MS
142
+ )
143
+ except Exception as e:
144
+ # 网络错误视为等待状态继续轮询
145
+ if verbose:
146
+ print(f".", end="", flush=True)
147
+ await asyncio.sleep(1)
148
+ continue
149
+ finally:
150
+ await client.close()
151
+
152
+ status = status_resp.get("status", "wait")
153
+
154
+ if on_status_change:
155
+ on_status_change(status)
156
+
157
+ if status == "wait":
158
+ if verbose:
159
+ print(".", end="", flush=True)
160
+
161
+ elif status == "scaned":
162
+ if not scanned_printed:
163
+ if verbose:
164
+ print("\n已扫码,在微信继续操作...")
165
+ scanned_printed = True
166
+
167
+ elif status == "expired":
168
+ qr_refresh_count += 1
169
+ if qr_refresh_count > MAX_QR_REFRESH_COUNT:
170
+ del self._active_logins[session_key]
171
+ return LoginResult(
172
+ connected=False,
173
+ message="登录超时:二维码多次过期,请重新开始登录流程。",
174
+ )
175
+
176
+ if verbose:
177
+ print(f"\n二维码已过期,正在刷新...({qr_refresh_count}/{MAX_QR_REFRESH_COUNT})")
178
+
179
+ try:
180
+ client = WeChatAPIClient(FIXED_BASE_URL)
181
+ resp = await client.get_bot_qrcode(self.bot_type)
182
+ await client.close()
183
+
184
+ active_login["qrcode"] = resp["qrcode"]
185
+ active_login["qrcode_url"] = resp["qrcode_img_content"]
186
+ active_login["started_at"] = asyncio.get_event_loop().time() * 1000
187
+ scanned_printed = False
188
+
189
+ if verbose:
190
+ print(f"新二维码已生成,请重新扫描")
191
+ print(f"URL: {resp['qrcode_img_content']}")
192
+ except Exception as e:
193
+ del self._active_logins[session_key]
194
+ return LoginResult(
195
+ connected=False,
196
+ message=f"刷新二维码失败: {e}",
197
+ )
198
+
199
+ elif status == "scaned_but_redirect":
200
+ redirect_host = status_resp.get("redirect_host")
201
+ if redirect_host:
202
+ active_login["current_api_base_url"] = f"https://{redirect_host}"
203
+ else:
204
+ pass # 继续用当前 host
205
+
206
+ elif status == "confirmed":
207
+ bot_token = status_resp.get("bot_token")
208
+ ilink_bot_id = status_resp.get("ilink_bot_id")
209
+ baseurl = status_resp.get("baseurl")
210
+ ilink_user_id = status_resp.get("ilink_user_id")
211
+
212
+ if not ilink_bot_id:
213
+ del self._active_logins[session_key]
214
+ return LoginResult(
215
+ connected=False,
216
+ message="登录失败:服务器未返回 ilink_bot_id。",
217
+ )
218
+
219
+ del self._active_logins[session_key]
220
+ return LoginResult(
221
+ connected=True,
222
+ bot_token=bot_token,
223
+ account_id=ilink_bot_id,
224
+ base_url=baseurl or FIXED_BASE_URL,
225
+ user_id=ilink_user_id,
226
+ message="与微信连接成功!",
227
+ )
228
+
229
+ await asyncio.sleep(1)
230
+
231
+ del self._active_logins[session_key]
232
+ return LoginResult(
233
+ connected=False,
234
+ message="登录超时,请重试。",
235
+ )
imchat/wechat/cdn.py ADDED
@@ -0,0 +1,200 @@
1
+ import os
2
+ import hashlib
3
+ import secrets
4
+ from typing import Optional, Tuple
5
+ import httpx
6
+
7
+ from .api import WeChatAPIClient
8
+ from .crypto import encrypt_aes_ecb, aes_ecb_padded_size, decrypt_aes_ecb, parse_aes_key
9
+ from .types import UploadMediaType, GetUploadUrlReq
10
+ from .exceptions import WeChatCDNError
11
+
12
+
13
+ ENABLE_CDN_URL_FALLBACK = True
14
+ UPLOAD_MAX_RETRIES = 3
15
+
16
+
17
+ class WeChatCDNClient:
18
+ """微信 CDN 客户端"""
19
+
20
+ def __init__(
21
+ self,
22
+ api_client: WeChatAPIClient,
23
+ cdn_base_url: str = "https://novac2c.cdn.weixin.qq.com/c2c",
24
+ ):
25
+ self.api = api_client
26
+ self.cdn_base_url = cdn_base_url.rstrip("/")
27
+ self._http = httpx.AsyncClient()
28
+
29
+ def _build_download_url(self, encrypted_query_param: str) -> str:
30
+ return f"{self.cdn_base_url}/download?encrypted_query_param={encrypted_query_param}"
31
+
32
+ def _build_upload_url(self, upload_param: str, filekey: str) -> str:
33
+ return (
34
+ f"{self.cdn_base_url}/upload?"
35
+ f"encrypted_query_param={upload_param}&filekey={filekey}"
36
+ )
37
+
38
+ async def upload_file(
39
+ self,
40
+ file_path: str,
41
+ to_user_id: str,
42
+ media_type: UploadMediaType = UploadMediaType.FILE,
43
+ ) -> "UploadedFileInfo":
44
+ """
45
+ 上传本地文件到微信 CDN
46
+ 流程: 读取文件 → 计算 MD5 → 生成 AES key → 获取上传 URL → 加密上传
47
+ """
48
+ with open(file_path, "rb") as f:
49
+ plaintext = f.read()
50
+
51
+ rawsize = len(plaintext)
52
+ rawfilemd5 = hashlib.md5(plaintext).hexdigest()
53
+ filesize = aes_ecb_padded_size(rawsize)
54
+ filekey = secrets.token_hex(16)
55
+ aeskey = secrets.token_bytes(16)
56
+
57
+ req = GetUploadUrlReq(
58
+ filekey=filekey,
59
+ media_type=int(media_type),
60
+ to_user_id=to_user_id,
61
+ rawsize=rawsize,
62
+ rawfilemd5=rawfilemd5,
63
+ filesize=filesize,
64
+ no_need_thumb=True,
65
+ aeskey=aeskey.hex(),
66
+ )
67
+
68
+ upload_url_resp = await self.api.get_upload_url(req)
69
+
70
+ upload_full_url = upload_url_resp.upload_full_url
71
+ upload_param = upload_url_resp.upload_param
72
+
73
+ if not upload_full_url and not upload_param:
74
+ raise WeChatCDNError("getUploadUrl returned no upload URL")
75
+
76
+ ciphertext = encrypt_aes_ecb(plaintext, aeskey)
77
+
78
+ # 确定 CDN 上传 URL
79
+ if upload_full_url and upload_full_url.strip():
80
+ cdn_url = upload_full_url.strip()
81
+ elif upload_param:
82
+ cdn_url = self._build_upload_url(upload_param, filekey)
83
+ else:
84
+ raise WeChatCDNError("CDN upload URL missing")
85
+
86
+ download_param = await self._upload_to_cdn(cdn_url, ciphertext, filekey)
87
+
88
+ return UploadedFileInfo(
89
+ filekey=filekey,
90
+ download_encrypted_query_param=download_param,
91
+ aeskey=aeskey.hex(),
92
+ file_size=rawsize,
93
+ file_size_ciphertext=filesize,
94
+ )
95
+
96
+ async def _upload_to_cdn(
97
+ self, cdn_url: str, ciphertext: bytes, filekey: str
98
+ ) -> str:
99
+ """上传加密后的数据到 CDN,带重试"""
100
+ last_error = None
101
+
102
+ for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
103
+ try:
104
+ resp = await self._http.post(
105
+ cdn_url,
106
+ headers={"Content-Type": "application/octet-stream"},
107
+ content=ciphertext,
108
+ )
109
+
110
+ if 400 <= resp.status_code < 500:
111
+ err_msg = resp.headers.get("x-error-message") or resp.text
112
+ raise WeChatCDNError(f"CDN upload client error {resp.status_code}: {err_msg}")
113
+
114
+ if resp.status_code != 200:
115
+ err_msg = resp.headers.get("x-error-message") or f"status {resp.status_code}"
116
+ raise WeChatCDNError(f"CDN upload server error: {err_msg}")
117
+
118
+ download_param = resp.headers.get("x-encrypted-param")
119
+ if not download_param:
120
+ raise WeChatCDNError("CDN upload response missing x-encrypted-param header")
121
+
122
+ return download_param
123
+
124
+ except WeChatCDNError as e:
125
+ last_error = e
126
+ if "client error" in str(e):
127
+ raise
128
+ if attempt < UPLOAD_MAX_RETRIES:
129
+ continue
130
+ else:
131
+ raise WeChatCDNError(
132
+ f"CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}"
133
+ )
134
+
135
+ raise last_error or WeChatCDNError("CDN upload failed")
136
+
137
+ async def download_and_decrypt(
138
+ self,
139
+ encrypted_query_param: str,
140
+ aes_key_base64: str,
141
+ full_url: Optional[str] = None,
142
+ ) -> bytes:
143
+ """下载并解密 CDN 文件"""
144
+ key = parse_aes_key(aes_key_base64)
145
+
146
+ if full_url:
147
+ url = full_url
148
+ elif ENABLE_CDN_URL_FALLBACK:
149
+ url = self._build_download_url(encrypted_query_param)
150
+ else:
151
+ raise WeChatCDNError("full_url is required (CDN URL fallback is disabled)")
152
+
153
+ resp = await self._http.get(url)
154
+ if not resp.is_success:
155
+ body = resp.text or "(unreadable)"
156
+ raise WeChatCDNError(f"CDN download {resp.status_code} {resp.reason_phrase}: {body}")
157
+
158
+ encrypted = resp.content
159
+ return decrypt_aes_ecb(encrypted, key)
160
+
161
+ async def download_plain(
162
+ self,
163
+ encrypted_query_param: str,
164
+ full_url: Optional[str] = None,
165
+ ) -> bytes:
166
+ """下载未加密的 CDN 文件"""
167
+ if full_url:
168
+ url = full_url
169
+ elif ENABLE_CDN_URL_FALLBACK:
170
+ url = self._build_download_url(encrypted_query_param)
171
+ else:
172
+ raise WeChatCDNError("full_url is required (CDN URL fallback is disabled)")
173
+
174
+ resp = await self._http.get(url)
175
+ if not resp.is_success:
176
+ body = resp.text or "(unreadable)"
177
+ raise WeChatCDNError(f"CDN download {resp.status_code} {resp.reason_phrase}: {body}")
178
+
179
+ return resp.content
180
+
181
+ async def close(self):
182
+ await self._http.aclose()
183
+
184
+
185
+ class UploadedFileInfo:
186
+ """上传后的文件信息"""
187
+
188
+ def __init__(
189
+ self,
190
+ filekey: str,
191
+ download_encrypted_query_param: str,
192
+ aeskey: str,
193
+ file_size: int,
194
+ file_size_ciphertext: int,
195
+ ):
196
+ self.filekey = filekey
197
+ self.download_encrypted_query_param = download_encrypted_query_param
198
+ self.aeskey = aeskey
199
+ self.file_size = file_size
200
+ self.file_size_ciphertext = file_size_ciphertext