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 +1 -0
- imchat/version.py +1 -0
- imchat/wechat/__init__.py +64 -0
- imchat/wechat/api.py +197 -0
- imchat/wechat/auth.py +235 -0
- imchat/wechat/cdn.py +200 -0
- imchat/wechat/client.py +439 -0
- imchat/wechat/crypto.py +63 -0
- imchat/wechat/exceptions.py +26 -0
- imchat/wechat/types.py +534 -0
- imchat/wechat/utils.py +25 -0
- imchat-0.0.0.dist-info/METADATA +382 -0
- imchat-0.0.0.dist-info/RECORD +15 -0
- imchat-0.0.0.dist-info/WHEEL +4 -0
- imchat-0.0.0.dist-info/licenses/LICENSE +339 -0
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
|