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/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