wechatbridge-cli 1.3.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.
- wechatbridge/__init__.py +2 -0
- wechatbridge/__main__.py +4 -0
- wechatbridge/agy.py +630 -0
- wechatbridge/config.py +266 -0
- wechatbridge/grok.py +682 -0
- wechatbridge/ilink.py +849 -0
- wechatbridge/main.py +755 -0
- wechatbridge/runner_common.py +1003 -0
- wechatbridge/update_check.py +175 -0
- wechatbridge_cli-1.3.0.dist-info/METADATA +29 -0
- wechatbridge_cli-1.3.0.dist-info/RECORD +15 -0
- wechatbridge_cli-1.3.0.dist-info/WHEEL +5 -0
- wechatbridge_cli-1.3.0.dist-info/entry_points.txt +2 -0
- wechatbridge_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- wechatbridge_cli-1.3.0.dist-info/top_level.txt +1 -0
wechatbridge/ilink.py
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""
|
|
2
|
+
iLink protocol client for WeChat ClawBot.
|
|
3
|
+
Handles QR code login, long polling message receiving, and message sending.
|
|
4
|
+
Picture download and AES-128-ECB decryption for iLink CDN images.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import hashlib
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Dict, List, Optional, Tuple
|
|
18
|
+
from urllib.parse import quote, urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .config import config
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("ilink")
|
|
25
|
+
|
|
26
|
+
ILINK_BASE = config.ilink_base_url.rstrip("/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_allowed_media_url(url: str) -> bool:
|
|
30
|
+
"""Only allow downloads from WeChat CDN hosts or the configured CDN base host."""
|
|
31
|
+
try:
|
|
32
|
+
parsed = urlparse(url)
|
|
33
|
+
except Exception:
|
|
34
|
+
return False
|
|
35
|
+
if parsed.scheme not in ("http", "https"):
|
|
36
|
+
return False
|
|
37
|
+
host = (parsed.hostname or "").lower()
|
|
38
|
+
if not host:
|
|
39
|
+
return False
|
|
40
|
+
if host == "weixin.qq.com" or host.endswith(".weixin.qq.com"):
|
|
41
|
+
return True
|
|
42
|
+
if host == "qq.com" or host.endswith(".qq.com"):
|
|
43
|
+
# WeChat CDN commonly uses *.cdn.weixin.qq.com / *.qq.com
|
|
44
|
+
if "weixin" in host or "cdn" in host or "novac" in host:
|
|
45
|
+
return True
|
|
46
|
+
try:
|
|
47
|
+
base_host = (urlparse(config.cdn_base_url).hostname or "").lower()
|
|
48
|
+
except Exception:
|
|
49
|
+
base_host = ""
|
|
50
|
+
if base_host and host == base_host:
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# iLink app identity (mirrors official @tencent-weixin/openclaw-weixin package.json)
|
|
55
|
+
ILINK_APP_ID = "bot"
|
|
56
|
+
# ClientVersion encoding: (major<<16)|(minor<<8)|patch; "2.4.6" -> 132102
|
|
57
|
+
ILINK_APP_CLIENT_VERSION = "132102"
|
|
58
|
+
CHANNEL_VERSION = "2.4.6"
|
|
59
|
+
BOT_AGENT = "OpenClaw"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_base_info() -> Dict[str, str]:
|
|
63
|
+
return {"channel_version": CHANNEL_VERSION, "bot_agent": BOT_AGENT}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _make_wechat_uin_header() -> str:
|
|
67
|
+
"""Generate X-WECHAT-UIN header: base64(str(random_uint32))."""
|
|
68
|
+
uin = random.randint(0, 0xFFFFFFFF)
|
|
69
|
+
uin_str = str(uin)
|
|
70
|
+
return base64.b64encode(uin_str.encode()).decode()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _common_headers(has_auth: bool = False, bot_token: str = "") -> Dict[str, str]:
|
|
74
|
+
"""Build common request headers per iLink spec."""
|
|
75
|
+
headers = {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
"AuthorizationType": "ilink_bot_token",
|
|
78
|
+
"X-WECHAT-UIN": _make_wechat_uin_header(),
|
|
79
|
+
"iLink-App-Id": ILINK_APP_ID,
|
|
80
|
+
"iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION,
|
|
81
|
+
}
|
|
82
|
+
if has_auth and bot_token:
|
|
83
|
+
headers["Authorization"] = f"Bearer {bot_token}"
|
|
84
|
+
return headers
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Image decryption helpers
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_aes_key(aes_key_b64: str) -> bytes:
|
|
93
|
+
"""Parse base64-encoded AES key from iLink CDN media.
|
|
94
|
+
|
|
95
|
+
Supports two formats:
|
|
96
|
+
- 16 raw bytes → direct AES-128 key
|
|
97
|
+
- 32 ASCII hex chars → hex-decode into 16 bytes
|
|
98
|
+
"""
|
|
99
|
+
decoded = base64.b64decode(aes_key_b64)
|
|
100
|
+
if len(decoded) == 16:
|
|
101
|
+
return decoded
|
|
102
|
+
if len(decoded) == 32:
|
|
103
|
+
try:
|
|
104
|
+
s = decoded.decode("ascii")
|
|
105
|
+
if all(c in "0123456789abcdefABCDEF" for c in s):
|
|
106
|
+
return bytes.fromhex(s)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"aes_key 解码后 {len(decoded)} 字节,既非 16 字节原始 key 也非 32 字节 hex 串"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
|
|
115
|
+
"""Decrypt AES-128-ECB ciphertext and remove PKCS7 padding."""
|
|
116
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
117
|
+
from cryptography.hazmat.primitives.padding import PKCS7
|
|
118
|
+
|
|
119
|
+
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
|
120
|
+
decryptor = cipher.decryptor()
|
|
121
|
+
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
122
|
+
unpadder = PKCS7(128).unpadder()
|
|
123
|
+
return unpadder.update(padded) + unpadder.finalize()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# AES encryption helpers (symmetric to _decrypt_aes_ecb above)
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
|
|
132
|
+
"""PKCS7 pad input data to a multiple of block_size."""
|
|
133
|
+
pad_len = block_size - (len(data) % block_size)
|
|
134
|
+
return data + bytes([pad_len] * pad_len)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
|
|
138
|
+
"""Encrypt plaintext with AES-128-ECB (PKCS7 padded)."""
|
|
139
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
140
|
+
|
|
141
|
+
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
|
142
|
+
encryptor = cipher.encryptor()
|
|
143
|
+
return encryptor.update(_pkcs7_pad(plaintext)) + encryptor.finalize()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _aes_padded_size(rawsize: int) -> int:
|
|
147
|
+
"""Compute the AES-ECB padded ciphertext size for a given raw size."""
|
|
148
|
+
return ((rawsize + 1 + 15) // 16) * 16
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# iLink state persistence
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ILinkState:
|
|
157
|
+
"""Persistent state for bot login (bot_token + baseurl)."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, path: str = config.state_file_path):
|
|
160
|
+
self.path = path
|
|
161
|
+
self.bot_token: str = ""
|
|
162
|
+
self.baseurl: str = ""
|
|
163
|
+
self.bound_at: int = 0
|
|
164
|
+
|
|
165
|
+
def load(self) -> bool:
|
|
166
|
+
"""Load state from file. Returns True if valid state loaded."""
|
|
167
|
+
logger.debug("Loading iLink state from %s", self.path)
|
|
168
|
+
if not os.path.exists(self.path):
|
|
169
|
+
logger.info("No iLink state file found, need to login")
|
|
170
|
+
return False
|
|
171
|
+
try:
|
|
172
|
+
with open(self.path, "r") as f:
|
|
173
|
+
data = json.load(f)
|
|
174
|
+
self.bot_token = data.get("bot_token", "")
|
|
175
|
+
self.baseurl = data.get("baseurl", "")
|
|
176
|
+
self.bound_at = data.get("bound_at", 0)
|
|
177
|
+
if not self.bot_token or not self.baseurl:
|
|
178
|
+
logger.warning("Incomplete iLink state file, need to re-login")
|
|
179
|
+
return False
|
|
180
|
+
logger.info(
|
|
181
|
+
"Loaded iLink state (bot_token=%s..., baseurl=%s)",
|
|
182
|
+
self.bot_token[:8] if len(self.bot_token) > 8 else "?",
|
|
183
|
+
self.baseurl,
|
|
184
|
+
)
|
|
185
|
+
return True
|
|
186
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
187
|
+
logger.warning("Failed to load iLink state: %s", e)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def save(self) -> None:
|
|
191
|
+
"""Persist state to file with restricted permissions."""
|
|
192
|
+
data = {
|
|
193
|
+
"bot_token": self.bot_token,
|
|
194
|
+
"baseurl": self.baseurl,
|
|
195
|
+
"bound_at": self.bound_at or int(time.time()),
|
|
196
|
+
}
|
|
197
|
+
logger.info(
|
|
198
|
+
"Saving iLink state (bot_token=%s..., baseurl=%s)",
|
|
199
|
+
self.bot_token[:8] if len(self.bot_token) > 8 else "?",
|
|
200
|
+
self.baseurl,
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
parent = os.path.dirname(os.path.abspath(self.path))
|
|
204
|
+
if parent:
|
|
205
|
+
os.makedirs(parent, exist_ok=True)
|
|
206
|
+
try:
|
|
207
|
+
os.chmod(parent, 0o700)
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
with open(self.path, "w") as f:
|
|
211
|
+
json.dump(data, f)
|
|
212
|
+
os.chmod(self.path, 0o600)
|
|
213
|
+
except OSError as e:
|
|
214
|
+
logger.error("Failed to save iLink state: %s", e)
|
|
215
|
+
|
|
216
|
+
def clear(self) -> None:
|
|
217
|
+
"""Remove state file (used on token expiration)."""
|
|
218
|
+
self.bot_token = ""
|
|
219
|
+
self.baseurl = ""
|
|
220
|
+
self.bound_at = 0
|
|
221
|
+
if os.path.exists(self.path):
|
|
222
|
+
os.remove(self.path)
|
|
223
|
+
logger.info("Cleared iLink state file")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# iLink API client
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ILinkClient:
|
|
232
|
+
"""iLink API client for WeChat ClawBot."""
|
|
233
|
+
|
|
234
|
+
def __init__(self):
|
|
235
|
+
self.http_client = httpx.AsyncClient(
|
|
236
|
+
timeout=httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0),
|
|
237
|
+
follow_redirects=True,
|
|
238
|
+
)
|
|
239
|
+
self.state = ILinkState()
|
|
240
|
+
|
|
241
|
+
async def close(self):
|
|
242
|
+
await self.http_client.aclose()
|
|
243
|
+
|
|
244
|
+
# ---- QR Code Login (no Authorization required) ----
|
|
245
|
+
|
|
246
|
+
async def get_qrcode(self) -> Tuple[str, str]:
|
|
247
|
+
"""
|
|
248
|
+
Request a QR code for WeChat binding.
|
|
249
|
+
Returns: (qrcode_str, qrcode_url)
|
|
250
|
+
"""
|
|
251
|
+
logger.info("Requesting QR code from iLink...")
|
|
252
|
+
headers = _common_headers(has_auth=False)
|
|
253
|
+
url = f"{ILINK_BASE}/ilink/bot/get_bot_qrcode?bot_type=3"
|
|
254
|
+
|
|
255
|
+
response = await self.http_client.get(url, headers=headers)
|
|
256
|
+
response.raise_for_status()
|
|
257
|
+
data = response.json()
|
|
258
|
+
|
|
259
|
+
qrcode_str = data.get("qrcode", "")
|
|
260
|
+
qrcode_img_content = data.get("qrcode_img_content", "")
|
|
261
|
+
|
|
262
|
+
if not qrcode_str:
|
|
263
|
+
raise RuntimeError(
|
|
264
|
+
f"get_bot_qrcode response missing 'qrcode': {data}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
logger.info("QR code received successfully")
|
|
268
|
+
return qrcode_str, qrcode_img_content
|
|
269
|
+
|
|
270
|
+
async def poll_qrcode_status(
|
|
271
|
+
self, qrcode_str: str, timeout: int = 180, interval: float = 1.5
|
|
272
|
+
) -> Tuple[str, str]:
|
|
273
|
+
"""
|
|
274
|
+
Poll QR code status until confirmed or timeout.
|
|
275
|
+
Returns: (bot_token, baseurl)
|
|
276
|
+
Raises TimeoutError on timeout.
|
|
277
|
+
"""
|
|
278
|
+
logger.info("Polling QR code status with backoff...")
|
|
279
|
+
url = f"{ILINK_BASE}/ilink/bot/get_qrcode_status"
|
|
280
|
+
headers = _common_headers(has_auth=False)
|
|
281
|
+
|
|
282
|
+
backoff = config.qrcode_poll_interval
|
|
283
|
+
start_time = time.monotonic()
|
|
284
|
+
while (time.monotonic() - start_time) < timeout:
|
|
285
|
+
params = {"qrcode": qrcode_str}
|
|
286
|
+
try:
|
|
287
|
+
response = await self.http_client.get(
|
|
288
|
+
url, headers=headers, params=params
|
|
289
|
+
)
|
|
290
|
+
response.raise_for_status()
|
|
291
|
+
data = response.json()
|
|
292
|
+
|
|
293
|
+
status = data.get("status", "waiting")
|
|
294
|
+
logger.info("QR code status: %s", status)
|
|
295
|
+
|
|
296
|
+
if status == "confirmed":
|
|
297
|
+
bot_token = data.get("bot_token", "")
|
|
298
|
+
baseurl = data.get("baseurl", "")
|
|
299
|
+
if not bot_token:
|
|
300
|
+
logger.warning(
|
|
301
|
+
"Status confirmed but no bot_token in response"
|
|
302
|
+
)
|
|
303
|
+
await asyncio.sleep(backoff)
|
|
304
|
+
backoff = min(backoff * 2 * random.uniform(0.5, 1.5), 30.0)
|
|
305
|
+
continue
|
|
306
|
+
logger.info("QR code confirmed! Bot token received.")
|
|
307
|
+
return bot_token, baseurl
|
|
308
|
+
|
|
309
|
+
await asyncio.sleep(backoff)
|
|
310
|
+
backoff = min(backoff * 2 * random.uniform(0.5, 1.5), 30.0)
|
|
311
|
+
|
|
312
|
+
except httpx.HTTPStatusError as e:
|
|
313
|
+
logger.warning("HTTP error polling QR code status: %s", e)
|
|
314
|
+
await asyncio.sleep(backoff)
|
|
315
|
+
backoff = min(backoff * 2 * random.uniform(0.5, 1.5), 30.0)
|
|
316
|
+
except httpx.RequestError as e:
|
|
317
|
+
logger.warning("Request error polling QR code status: %s", e)
|
|
318
|
+
await asyncio.sleep(backoff)
|
|
319
|
+
backoff = min(backoff * 2 * random.uniform(0.5, 1.5), 30.0)
|
|
320
|
+
|
|
321
|
+
raise TimeoutError(
|
|
322
|
+
f"QR code polling timed out after {timeout}s"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# ---- Message Operations (Authorization required) ----
|
|
326
|
+
|
|
327
|
+
async def get_updates(
|
|
328
|
+
self, buf: str, baseurl: str, bot_token: str
|
|
329
|
+
) -> Tuple[List[Dict], str]:
|
|
330
|
+
"""
|
|
331
|
+
Long polling get updates from iLink.
|
|
332
|
+
Returns: (list_of_messages, new_buf)
|
|
333
|
+
Retains old buf on error.
|
|
334
|
+
"""
|
|
335
|
+
url = f"{baseurl}/ilink/bot/getupdates"
|
|
336
|
+
headers = _common_headers(has_auth=True, bot_token=bot_token)
|
|
337
|
+
body = {
|
|
338
|
+
"get_updates_buf": buf,
|
|
339
|
+
"base_info": _build_base_info(),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
response = await self.http_client.post(
|
|
344
|
+
url, headers=headers, json=body
|
|
345
|
+
)
|
|
346
|
+
response.raise_for_status()
|
|
347
|
+
data = response.json()
|
|
348
|
+
|
|
349
|
+
ret = data.get("ret", -1)
|
|
350
|
+
msgs = data.get("msgs", [])
|
|
351
|
+
new_buf = data.get("get_updates_buf", buf)
|
|
352
|
+
if ret != 0 and not msgs:
|
|
353
|
+
if ret == -1:
|
|
354
|
+
logger.debug("getupdates empty poll (ret=-1, no msgs)")
|
|
355
|
+
else:
|
|
356
|
+
logger.warning("getupdates returned ret=%s, keys=%s", ret, list(data.keys()))
|
|
357
|
+
return [], buf
|
|
358
|
+
if ret != 0:
|
|
359
|
+
logger.info("getupdates ret=%s but has %d msgs, processing", ret, len(msgs))
|
|
360
|
+
return msgs, new_buf
|
|
361
|
+
|
|
362
|
+
except httpx.HTTPStatusError as e:
|
|
363
|
+
if e.response.status_code in (401, 403):
|
|
364
|
+
logger.warning("Bot token rejected (401/403), clearing state")
|
|
365
|
+
self.state.clear()
|
|
366
|
+
raise # Let caller re-login
|
|
367
|
+
logger.warning("HTTP error in get_updates: %s", e)
|
|
368
|
+
return [], buf
|
|
369
|
+
except httpx.RequestError as e:
|
|
370
|
+
logger.warning("Network error in get_updates: %s", e)
|
|
371
|
+
return [], buf
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.exception("Unexpected error in get_updates: %s", e)
|
|
374
|
+
return [], buf
|
|
375
|
+
|
|
376
|
+
# ---- Media upload and sending ----
|
|
377
|
+
|
|
378
|
+
async def get_upload_url(
|
|
379
|
+
self,
|
|
380
|
+
to_user_id: str,
|
|
381
|
+
baseurl: str,
|
|
382
|
+
bot_token: str,
|
|
383
|
+
media_type: int,
|
|
384
|
+
filekey: str,
|
|
385
|
+
rawsize: int,
|
|
386
|
+
rawfilemd5: str,
|
|
387
|
+
filesize: int,
|
|
388
|
+
aeskey_hex: str,
|
|
389
|
+
) -> Tuple[str, str]:
|
|
390
|
+
"""Obtain a CDN upload URL for encrypted media.
|
|
391
|
+
|
|
392
|
+
POSTs to {baseurl}/ilink/bot/getuploadurl with media metadata.
|
|
393
|
+
Returns (upload_param, upload_full_url).
|
|
394
|
+
|
|
395
|
+
Reference: hermes weixin.py _get_upload_url ~L518-548
|
|
396
|
+
"""
|
|
397
|
+
url = f"{baseurl}/ilink/bot/getuploadurl"
|
|
398
|
+
headers = _common_headers(has_auth=True, bot_token=bot_token)
|
|
399
|
+
body = {
|
|
400
|
+
"filekey": filekey,
|
|
401
|
+
"media_type": media_type,
|
|
402
|
+
"to_user_id": to_user_id,
|
|
403
|
+
"rawsize": rawsize,
|
|
404
|
+
"rawfilemd5": rawfilemd5,
|
|
405
|
+
"filesize": filesize,
|
|
406
|
+
"no_need_thumb": True,
|
|
407
|
+
"aeskey": aeskey_hex,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
response = await self.http_client.post(url, headers=headers, json=body)
|
|
412
|
+
response.raise_for_status()
|
|
413
|
+
data = response.json()
|
|
414
|
+
upload_param = str(data.get("upload_param", "") or "")
|
|
415
|
+
upload_full_url = str(data.get("upload_full_url", "") or "")
|
|
416
|
+
if not upload_param and not upload_full_url:
|
|
417
|
+
logger.warning(
|
|
418
|
+
"getUploadUrl returned neither upload_param nor upload_full_url: %s",
|
|
419
|
+
data,
|
|
420
|
+
)
|
|
421
|
+
logger.debug(
|
|
422
|
+
"getUploadUrl success: upload_param=%.80s upload_full_url=%.200s",
|
|
423
|
+
upload_param, upload_full_url,
|
|
424
|
+
)
|
|
425
|
+
return upload_param, upload_full_url
|
|
426
|
+
except httpx.HTTPStatusError as e:
|
|
427
|
+
logger.error("HTTP error in get_upload_url: %s", e)
|
|
428
|
+
raise
|
|
429
|
+
except httpx.RequestError as e:
|
|
430
|
+
logger.error("Network error in get_upload_url: %s", e)
|
|
431
|
+
raise
|
|
432
|
+
|
|
433
|
+
async def upload_ciphertext(self, upload_url: str, ciphertext: bytes) -> str:
|
|
434
|
+
"""Upload encrypted media bytes to the CDN via POST.
|
|
435
|
+
|
|
436
|
+
The CDN expects POST with Content-Type application/octet-stream.
|
|
437
|
+
Returns the x-encrypted-param header value for use in sendmessage.
|
|
438
|
+
|
|
439
|
+
Reference: hermes weixin.py _upload_ciphertext ~L550-574
|
|
440
|
+
"""
|
|
441
|
+
headers = {"Content-Type": "application/octet-stream"}
|
|
442
|
+
try:
|
|
443
|
+
t0 = time.time()
|
|
444
|
+
response = await self.http_client.post(
|
|
445
|
+
upload_url,
|
|
446
|
+
content=ciphertext,
|
|
447
|
+
headers=headers,
|
|
448
|
+
timeout=config.cdn_upload_timeout,
|
|
449
|
+
)
|
|
450
|
+
response.raise_for_status()
|
|
451
|
+
encrypted_param = response.headers.get("x-encrypted-param")
|
|
452
|
+
if not encrypted_param:
|
|
453
|
+
raise RuntimeError(
|
|
454
|
+
f"CDN upload missing x-encrypted-param header: {response.text[:200]}"
|
|
455
|
+
)
|
|
456
|
+
elapsed = time.time() - t0
|
|
457
|
+
logger.info(
|
|
458
|
+
"CDN upload done: %d bytes in %.3fs x-encrypted-param=%.80s",
|
|
459
|
+
len(ciphertext), elapsed, encrypted_param,
|
|
460
|
+
)
|
|
461
|
+
return encrypted_param
|
|
462
|
+
except httpx.TimeoutException:
|
|
463
|
+
logger.error("CDN upload timed out after %ss", config.cdn_upload_timeout)
|
|
464
|
+
raise
|
|
465
|
+
except httpx.HTTPStatusError as e:
|
|
466
|
+
logger.error("CDN upload HTTP %s: %s", e.response.status_code, e)
|
|
467
|
+
raise
|
|
468
|
+
|
|
469
|
+
async def send_media(
|
|
470
|
+
self,
|
|
471
|
+
to_user_id: str,
|
|
472
|
+
baseurl: str,
|
|
473
|
+
bot_token: str,
|
|
474
|
+
context_token: str,
|
|
475
|
+
path: str,
|
|
476
|
+
caption: str = "",
|
|
477
|
+
) -> bool:
|
|
478
|
+
"""Encrypt and send a media file (image or file) via WeChat iLink.
|
|
479
|
+
|
|
480
|
+
1. Reads the file, computes rawsize/rawfilemd5/filekey/aes_key.
|
|
481
|
+
2. Encrypts plaintext with AES-128-ECB.
|
|
482
|
+
3. Gets CDN upload URL via get_upload_url.
|
|
483
|
+
4. Uploads ciphertext via upload_ciphertext.
|
|
484
|
+
5. Builds media item (image_item or file_item depending on mime type).
|
|
485
|
+
6. If caption provided, sends text message first.
|
|
486
|
+
7. Sends media message.
|
|
487
|
+
|
|
488
|
+
Returns True if the media message was sent successfully.
|
|
489
|
+
|
|
490
|
+
Reference: hermes weixin.py _send_media ~L2110-2170
|
|
491
|
+
"""
|
|
492
|
+
logger.info("send_media: %s for user %s", path, to_user_id)
|
|
493
|
+
try:
|
|
494
|
+
with open(path, "rb") as f:
|
|
495
|
+
plaintext = f.read()
|
|
496
|
+
except OSError as e:
|
|
497
|
+
logger.error("Cannot read file %s: %s", path, e)
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
rawsize = len(plaintext)
|
|
501
|
+
rawfilemd5 = hashlib.md5(plaintext).hexdigest()
|
|
502
|
+
filekey = os.urandom(16).hex()
|
|
503
|
+
aes_key = os.urandom(16)
|
|
504
|
+
logger.debug(
|
|
505
|
+
"send_media: rawsize=%d rawfilemd5=%s filekey=%s aes_key=%.8s...",
|
|
506
|
+
rawsize, rawfilemd5, filekey, aes_key.hex(),
|
|
507
|
+
)
|
|
508
|
+
ciphertext = _encrypt_aes_ecb(plaintext, aes_key)
|
|
509
|
+
filesize = len(ciphertext)
|
|
510
|
+
|
|
511
|
+
# Determine media_type from mime
|
|
512
|
+
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
513
|
+
if mime.startswith("image/"):
|
|
514
|
+
media_type = 1 # MEDIA_IMAGE
|
|
515
|
+
else:
|
|
516
|
+
media_type = 3 # MEDIA_FILE
|
|
517
|
+
|
|
518
|
+
# Get CDN upload URL
|
|
519
|
+
upload_param, upload_full_url = await self.get_upload_url(
|
|
520
|
+
to_user_id=to_user_id,
|
|
521
|
+
baseurl=baseurl,
|
|
522
|
+
bot_token=bot_token,
|
|
523
|
+
media_type=media_type,
|
|
524
|
+
filekey=filekey,
|
|
525
|
+
rawsize=rawsize,
|
|
526
|
+
rawfilemd5=rawfilemd5,
|
|
527
|
+
filesize=filesize,
|
|
528
|
+
aeskey_hex=aes_key.hex(),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Prefer upload_full_url (direct CDN), fall back to constructed CDN URL
|
|
532
|
+
# from upload_param (reference: weixin.py _cdn_upload_url).
|
|
533
|
+
if upload_full_url:
|
|
534
|
+
upload_url = upload_full_url
|
|
535
|
+
elif upload_param:
|
|
536
|
+
upload_url = (
|
|
537
|
+
f"{config.cdn_base_url}/upload"
|
|
538
|
+
f"?encrypted_query_param={quote(upload_param, safe='')}"
|
|
539
|
+
f"&filekey={quote(filekey, safe='')}"
|
|
540
|
+
)
|
|
541
|
+
else:
|
|
542
|
+
raise RuntimeError(
|
|
543
|
+
"getUploadUrl returned neither upload_param nor upload_full_url"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Upload ciphertext
|
|
547
|
+
encrypt_query_param = await self.upload_ciphertext(
|
|
548
|
+
upload_url=upload_url, ciphertext=ciphertext
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Build media item
|
|
552
|
+
# Key encoding: base64(aes_key.hex()) — NOT base64(raw_bytes).
|
|
553
|
+
# Sending base64(raw_bytes) causes grey-box images on receiver side.
|
|
554
|
+
aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode()
|
|
555
|
+
|
|
556
|
+
filename = os.path.basename(path)
|
|
557
|
+
if media_type == 1: # image
|
|
558
|
+
item = {
|
|
559
|
+
"type": 2, # ITEM_IMAGE
|
|
560
|
+
"image_item": {
|
|
561
|
+
"media": {
|
|
562
|
+
"encrypt_query_param": encrypt_query_param,
|
|
563
|
+
"aes_key": aes_key_for_api,
|
|
564
|
+
"encrypt_type": 1,
|
|
565
|
+
},
|
|
566
|
+
"mid_size": filesize,
|
|
567
|
+
},
|
|
568
|
+
}
|
|
569
|
+
else: # file
|
|
570
|
+
item = {
|
|
571
|
+
"type": 4, # ITEM_FILE
|
|
572
|
+
"file_item": {
|
|
573
|
+
"media": {
|
|
574
|
+
"encrypt_query_param": encrypt_query_param,
|
|
575
|
+
"aes_key": aes_key_for_api,
|
|
576
|
+
"encrypt_type": 1,
|
|
577
|
+
},
|
|
578
|
+
"file_name": filename,
|
|
579
|
+
"len": str(rawsize),
|
|
580
|
+
},
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if caption:
|
|
584
|
+
caption_ok = await self.send_message(
|
|
585
|
+
to_user_id=to_user_id,
|
|
586
|
+
text=caption,
|
|
587
|
+
context_token=context_token,
|
|
588
|
+
baseurl=baseurl,
|
|
589
|
+
bot_token=bot_token,
|
|
590
|
+
)
|
|
591
|
+
if not caption_ok:
|
|
592
|
+
logger.warning("send_media: caption send failed, continuing with media")
|
|
593
|
+
|
|
594
|
+
return await self.send_media_message(
|
|
595
|
+
to_user_id=to_user_id,
|
|
596
|
+
baseurl=baseurl,
|
|
597
|
+
bot_token=bot_token,
|
|
598
|
+
context_token=context_token,
|
|
599
|
+
item=item,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
async def _post_sendmessage_with_retry(
|
|
603
|
+
self,
|
|
604
|
+
url: str,
|
|
605
|
+
headers: dict,
|
|
606
|
+
body: dict,
|
|
607
|
+
to_user_id: str,
|
|
608
|
+
log_label: str = "sendmessage",
|
|
609
|
+
max_attempts: int = 5,
|
|
610
|
+
) -> bool:
|
|
611
|
+
"""Helper to POST to /sendmessage with exponential backoff + jitter retry strategy.
|
|
612
|
+
|
|
613
|
+
- Retries on network errors (httpx.RequestError) and 5xx HTTP status errors.
|
|
614
|
+
- Fails fast on 401/403 (clears auth state) and 4xx status errors.
|
|
615
|
+
- Retries up to max_attempts with exponential backoff + random jitter.
|
|
616
|
+
"""
|
|
617
|
+
attempt = 0
|
|
618
|
+
base_delay = 1.0
|
|
619
|
+
while attempt < max_attempts:
|
|
620
|
+
attempt += 1
|
|
621
|
+
try:
|
|
622
|
+
response = await self.http_client.post(
|
|
623
|
+
url, headers=headers, json=body
|
|
624
|
+
)
|
|
625
|
+
response.raise_for_status()
|
|
626
|
+
data = response.json()
|
|
627
|
+
|
|
628
|
+
ret = data.get("ret", -1)
|
|
629
|
+
message_id = data.get("message_id", "")
|
|
630
|
+
if ret != 0 and not message_id:
|
|
631
|
+
logger.warning(
|
|
632
|
+
"%s failed ret=%s for %s (attempt %d/%d): %s",
|
|
633
|
+
log_label, ret, to_user_id, attempt, max_attempts, data,
|
|
634
|
+
)
|
|
635
|
+
if attempt < max_attempts:
|
|
636
|
+
delay = min(base_delay * (2 ** (attempt - 1)) * random.uniform(0.7, 1.3), 30.0)
|
|
637
|
+
await asyncio.sleep(delay)
|
|
638
|
+
continue
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
if ret != 0 and message_id:
|
|
642
|
+
logger.warning(
|
|
643
|
+
"%s ret=%s but has message_id=%s, treating as success",
|
|
644
|
+
log_label, ret, message_id,
|
|
645
|
+
)
|
|
646
|
+
logger.info(
|
|
647
|
+
"%s sent to %s (attempt %d/%d): msg_id=%s",
|
|
648
|
+
log_label, to_user_id, attempt, max_attempts, data.get("message_id"),
|
|
649
|
+
)
|
|
650
|
+
return True
|
|
651
|
+
|
|
652
|
+
except httpx.HTTPStatusError as e:
|
|
653
|
+
if e.response.status_code in (401, 403):
|
|
654
|
+
logger.warning(
|
|
655
|
+
"Bot token rejected during %s (401/403), clearing state", log_label
|
|
656
|
+
)
|
|
657
|
+
self.state.clear()
|
|
658
|
+
return False
|
|
659
|
+
elif e.response.status_code >= 500:
|
|
660
|
+
logger.warning(
|
|
661
|
+
"HTTP 5xx server error during %s (attempt %d/%d): %s",
|
|
662
|
+
log_label, attempt, max_attempts, e,
|
|
663
|
+
)
|
|
664
|
+
else:
|
|
665
|
+
logger.warning(
|
|
666
|
+
"HTTP client error %s during %s: %s",
|
|
667
|
+
e.response.status_code, log_label, e,
|
|
668
|
+
)
|
|
669
|
+
return False
|
|
670
|
+
|
|
671
|
+
except httpx.RequestError as e:
|
|
672
|
+
logger.warning(
|
|
673
|
+
"Network error during %s (attempt %d/%d): %s",
|
|
674
|
+
log_label, attempt, max_attempts, e,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
except Exception as e:
|
|
678
|
+
logger.exception(
|
|
679
|
+
"Unexpected error during %s (attempt %d/%d): %s",
|
|
680
|
+
log_label, attempt, max_attempts, e,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if attempt < max_attempts:
|
|
684
|
+
delay = min(base_delay * (2 ** (attempt - 1)) * random.uniform(0.7, 1.3), 30.0)
|
|
685
|
+
logger.info("Retrying %s in %.2fs...", log_label, delay)
|
|
686
|
+
await asyncio.sleep(delay)
|
|
687
|
+
|
|
688
|
+
logger.error(
|
|
689
|
+
"Failed to send %s to %s after %d attempts",
|
|
690
|
+
log_label, to_user_id, max_attempts,
|
|
691
|
+
)
|
|
692
|
+
return False
|
|
693
|
+
|
|
694
|
+
async def send_media_message(
|
|
695
|
+
self,
|
|
696
|
+
to_user_id: str,
|
|
697
|
+
baseurl: str,
|
|
698
|
+
bot_token: str,
|
|
699
|
+
context_token: str,
|
|
700
|
+
item: dict,
|
|
701
|
+
) -> bool:
|
|
702
|
+
"""Send a media message (image/file/voice/video) via iLink sendmessage."""
|
|
703
|
+
url = f"{baseurl}/ilink/bot/sendmessage"
|
|
704
|
+
headers = _common_headers(has_auth=True, bot_token=bot_token)
|
|
705
|
+
client_id = f"wechatbridge-{uuid.uuid4().hex[:16]}"
|
|
706
|
+
body = {
|
|
707
|
+
"msg": {
|
|
708
|
+
"from_user_id": "",
|
|
709
|
+
"to_user_id": to_user_id,
|
|
710
|
+
"client_id": client_id,
|
|
711
|
+
"message_type": 2,
|
|
712
|
+
"message_state": 2,
|
|
713
|
+
"context_token": context_token,
|
|
714
|
+
"item_list": [item],
|
|
715
|
+
},
|
|
716
|
+
"base_info": _build_base_info(),
|
|
717
|
+
}
|
|
718
|
+
return await self._post_sendmessage_with_retry(
|
|
719
|
+
url=url, headers=headers, body=body, to_user_id=to_user_id, log_label="send_media_message"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
async def send_message(
|
|
723
|
+
self,
|
|
724
|
+
to_user_id: str,
|
|
725
|
+
text: str,
|
|
726
|
+
context_token: str,
|
|
727
|
+
baseurl: str,
|
|
728
|
+
bot_token: str,
|
|
729
|
+
) -> bool:
|
|
730
|
+
"""Send a text message reply via iLink with automatic retries.
|
|
731
|
+
|
|
732
|
+
Long texts are split into chunks (config.message_chunk_chars) so WeChat
|
|
733
|
+
length limits do not silently drop the whole reply.
|
|
734
|
+
"""
|
|
735
|
+
from .runner_common import split_message_chunks
|
|
736
|
+
|
|
737
|
+
chunks = split_message_chunks(text, config.message_chunk_chars)
|
|
738
|
+
url = f"{baseurl}/ilink/bot/sendmessage"
|
|
739
|
+
headers = _common_headers(has_auth=True, bot_token=bot_token)
|
|
740
|
+
all_ok = True
|
|
741
|
+
for idx, chunk in enumerate(chunks):
|
|
742
|
+
client_id = f"wechatbridge-{uuid.uuid4().hex[:16]}"
|
|
743
|
+
body = {
|
|
744
|
+
"msg": {
|
|
745
|
+
"from_user_id": "",
|
|
746
|
+
"to_user_id": to_user_id,
|
|
747
|
+
"client_id": client_id,
|
|
748
|
+
"message_type": 2,
|
|
749
|
+
"message_state": 2,
|
|
750
|
+
"context_token": context_token,
|
|
751
|
+
"item_list": [
|
|
752
|
+
{"type": 1, "text_item": {"text": chunk}}
|
|
753
|
+
],
|
|
754
|
+
},
|
|
755
|
+
"base_info": _build_base_info(),
|
|
756
|
+
}
|
|
757
|
+
label = "send_message" if len(chunks) == 1 else f"send_message[{idx + 1}/{len(chunks)}]"
|
|
758
|
+
ok = await self._post_sendmessage_with_retry(
|
|
759
|
+
url=url, headers=headers, body=body, to_user_id=to_user_id, log_label=label
|
|
760
|
+
)
|
|
761
|
+
if not ok:
|
|
762
|
+
all_ok = False
|
|
763
|
+
return all_ok
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ---- Image download and decryption ----
|
|
767
|
+
|
|
768
|
+
async def download_and_decrypt_media(self, media: dict) -> bytes:
|
|
769
|
+
"""Download a CDN-encrypted media file (image or file), AES-128-ECB decrypt it,
|
|
770
|
+
and return plaintext bytes.
|
|
771
|
+
|
|
772
|
+
This method handles both image_item.media (type=2) and file_item.media
|
|
773
|
+
(type=4) — the media dict structure is identical for all encrypted media
|
|
774
|
+
types in iLink.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
media: Media dict from iLink message item_list[].image_item.media
|
|
778
|
+
or file_item.media. Expected keys: encrypt_query_param,
|
|
779
|
+
aes_key, full_url (optional).
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
Decrypted plaintext bytes (image or file content).
|
|
783
|
+
|
|
784
|
+
Raises:
|
|
785
|
+
ValueError: Missing required fields or invalid AES key.
|
|
786
|
+
httpx.HTTPError: Download failure.
|
|
787
|
+
"""
|
|
788
|
+
encrypt_query_param = media.get("encrypt_query_param", "")
|
|
789
|
+
aes_key_b64 = media.get("aes_key", "")
|
|
790
|
+
full_url = media.get("full_url", "")
|
|
791
|
+
|
|
792
|
+
if not aes_key_b64 or (not encrypt_query_param and not full_url):
|
|
793
|
+
raise ValueError(
|
|
794
|
+
"media 缺少 aes_key 或 encrypt_query_param/full_url"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Parse AES key
|
|
798
|
+
key = _parse_aes_key(aes_key_b64)
|
|
799
|
+
|
|
800
|
+
# Build download URL
|
|
801
|
+
if full_url:
|
|
802
|
+
url = full_url
|
|
803
|
+
else:
|
|
804
|
+
url = f"{config.cdn_base_url}/download?encrypted_query_param={quote(encrypt_query_param)}"
|
|
805
|
+
|
|
806
|
+
if not _is_allowed_media_url(url):
|
|
807
|
+
raise ValueError(f"拒绝非微信 CDN 下载地址: {url[:120]}")
|
|
808
|
+
|
|
809
|
+
# Stream download; abort as soon as size exceeds cap (even without Content-Length)
|
|
810
|
+
max_in = max(int(config.max_inbound_file_bytes), 1)
|
|
811
|
+
logger.info("Downloading encrypted media from CDN: %s", url[:80])
|
|
812
|
+
t0 = time.time()
|
|
813
|
+
buf = bytearray()
|
|
814
|
+
async with self.http_client.stream("GET", url, timeout=30.0) as resp:
|
|
815
|
+
resp.raise_for_status()
|
|
816
|
+
cl = resp.headers.get("content-length")
|
|
817
|
+
if cl is not None:
|
|
818
|
+
try:
|
|
819
|
+
if int(cl) > max_in:
|
|
820
|
+
raise ValueError(
|
|
821
|
+
f"入站文件过大(Content-Length {cl} > {max_in} 字节)"
|
|
822
|
+
)
|
|
823
|
+
except ValueError as e:
|
|
824
|
+
if "入站文件过大" in str(e):
|
|
825
|
+
raise
|
|
826
|
+
async for piece in resp.aiter_bytes():
|
|
827
|
+
if not piece:
|
|
828
|
+
continue
|
|
829
|
+
if len(buf) + len(piece) > max_in:
|
|
830
|
+
raise ValueError(
|
|
831
|
+
f"入站文件过大(>{max_in} 字节,已中止下载)"
|
|
832
|
+
)
|
|
833
|
+
buf.extend(piece)
|
|
834
|
+
encrypted = bytes(buf)
|
|
835
|
+
if len(encrypted) > max_in:
|
|
836
|
+
raise ValueError(
|
|
837
|
+
f"入站文件过大({len(encrypted)} > {max_in} 字节)"
|
|
838
|
+
)
|
|
839
|
+
elapsed = time.time() - t0
|
|
840
|
+
logger.debug("CDN download: %d bytes in %.3fs", len(encrypted), elapsed)
|
|
841
|
+
|
|
842
|
+
# AES-128-ECB decrypt
|
|
843
|
+
plaintext = _decrypt_aes_ecb(encrypted, key)
|
|
844
|
+
if len(plaintext) > max_in:
|
|
845
|
+
raise ValueError(
|
|
846
|
+
f"入站解密后文件过大({len(plaintext)} > {max_in} 字节)"
|
|
847
|
+
)
|
|
848
|
+
logger.info("Media decrypted: %d bytes -> %d bytes", len(encrypted), len(plaintext))
|
|
849
|
+
return plaintext
|