""" iLink CDN 媒体加解密 —— 微信官方 iLink(ClawBot) 协议媒体内联发送核心模块 im_ilink_media.py 参考微信官方 OpenClaw 插件 (@tencent-weixin/openclaw-weixin) 源码实现: - cdn/aes-ecb.ts: AES-128-ECB + PKCS7 加解密 - cdn/upload.ts: 六步上传流程 - messaging/send.ts: image_item / video_item 构造 严格照抄官方行为,保证字段名与编码方式与微信服务器一致: - aeskey = 随机 16 字节 - getUploadUrl 传 aeskey.hex()(32 字符 hex) - 加密用 16 字节原始 key - sendmessage 的 media.aes_key = base64(hex 字符串的 UTF-8 字节) 媒体类型映射: IMAGE=1, VIDEO=2, FILE=3, VOICE=4 """ import base64 import hashlib import math import os import re from urllib.parse import quote import requests from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding # 微信 CDN 域名(图片/语音/视频媒体文件走这里) WECHAT_CDN_BASE = "https://novac2c.cdn.weixin.qq.com/c2c" # 媒体类型枚举(与 iLink UploadMediaType 一致) MEDIA_TYPE_IMAGE = 1 MEDIA_TYPE_VIDEO = 2 MEDIA_TYPE_FILE = 3 MEDIA_TYPE_VOICE = 4 def encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes: """AES-128-ECB + PKCS7 加密(微信指定,非标准 CBC)。""" padder = padding.PKCS7(128).padder() padded = padder.update(plaintext) + padder.finalize() cipher = Cipher(algorithms.AES(key), modes.ECB()) enc = cipher.encryptor() return enc.update(padded) + enc.finalize() def decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes: """AES-128-ECB + PKCS7 解密。""" cipher = Cipher(algorithms.AES(key), modes.ECB()) dec = cipher.decryptor() padded = dec.update(ciphertext) + dec.finalize() unpadder = padding.PKCS7(128).unpadder() return unpadder.update(padded) + unpadder.finalize() def aes_ecb_padded_size(plaintext_size: int) -> int: """AES-128-ECB PKCS7 填充后的密文大小。""" return math.ceil((plaintext_size + 1) / 16) * 16 def _wechat_headers(bot_token: str) -> dict: """iLink 协议请求头(复制 im_bot_user 逻辑,避免循环依赖)。""" uin = base64.b64encode(str(random_int()).encode("utf-8")).decode("utf-8") return { "Content-Type": "application/json", "Authorization": f"Bearer {bot_token}", "AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": uin, } def random_int(): import random return random.randint(0, 2 ** 32 - 1) def download_media_bytes(url: str) -> bytes: """下载 AGNES/供应商媒体字节(走 HK 代理,绕过劫持)。 复用 chat_utils._proxy_for_url 的代理选择逻辑;对境外媒体走 HK 代理。 """ from app.api.chat_utils import _proxy_for_url proxy = _proxy_for_url(url) proxies = None if proxy: proxies = {"http": proxy, "https": proxy} # trust_env=False 语义等价:requests 不读环境变量代理 # 显式传 proxies=None 且环境变量存在时 requests 会用环境变量,故需清空 import os as _os env_bak = {} for _k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"): if _k in _os.environ: env_bak[_k] = _os.environ.pop(_k) try: resp = requests.get(url, proxies=proxies, timeout=180, allow_redirects=True) finally: _os.environ.update(env_bak) if not resp.ok: raise RuntimeError(f"media download failed: {resp.status_code} url={url[:120]}") return resp.content def upload_media_to_wechat_cdn( baseurl: str, bot_token: str, to_user_id: str, media_bytes: bytes, media_type: int, ) -> dict: """六步上传流程:读文件→hash→gen aeskey→getUploadUrl→加密上传CDN→返回引用。 返回 uploaded dict: { filekey, download_param, aeskey_hex, filesize } """ rawsize = len(media_bytes) rawfilemd5 = hashlib.md5(media_bytes).hexdigest() filesize = aes_ecb_padded_size(rawsize) filekey = os.urandom(16).hex() # 32 字符 hex aeskey = os.urandom(16) # 16 字节原始 key aeskey_hex = aeskey.hex() # 32 字符 hex # 1. getUploadUrl resp = requests.post( baseurl.rstrip("/") + "/ilink/bot/getuploadurl", headers=_wechat_headers(bot_token), json={ "filekey": filekey, "media_type": media_type, "to_user_id": to_user_id, "rawsize": rawsize, "rawfilemd5": rawfilemd5, "filesize": filesize, "no_need_thumb": True, "aeskey": aeskey_hex, }, timeout=30, ) if not resp.ok: raise RuntimeError(f"getuploadurl HTTP {resp.status_code}: {resp.text[:200]}") body = resp.json() if resp.content else {} upload_param = body.get("upload_param") upload_full_url = (body.get("upload_full_url") or "").strip() if not upload_param and not upload_full_url: raise RuntimeError(f"getuploadurl returned no upload URL: {str(body)[:200]}") # 2. AES-128-ECB 加密 ciphertext = encrypt_aes_ecb(media_bytes, aeskey) # 3. 上传 CDN if upload_full_url: cdn_url = upload_full_url else: cdn_url = ( f"{WECHAT_CDN_BASE}/upload" f"?encrypted_query_param={quote(upload_param, safe='')}" f"&filekey={quote(filekey, safe='')}" ) up_resp = requests.post( cdn_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=120, ) if up_resp.status_code not in (200, 201): raise RuntimeError(f"CDN upload failed HTTP {up_resp.status_code}: {up_resp.text[:200]}") download_param = up_resp.headers.get("x-encrypted-param") if not download_param: raise RuntimeError("CDN upload response missing x-encrypted-param header") return { "filekey": filekey, "download_param": download_param, "aeskey_hex": aeskey_hex, "filesize": filesize, } def _aes_key_b64(aeskey_hex: str) -> str: """media.aes_key 编码:base64(hex 字符串的 UTF-8 字节)。 严格对齐 openclaw: Buffer.from(hexString).toString('base64') """ return base64.b64encode(aeskey_hex.encode("utf-8")).decode("utf-8") def build_image_item(uploaded: dict) -> dict: """构造图片 MessageItem(type=2)。""" return { "type": 2, "image_item": { "media": { "encrypt_query_param": uploaded["download_param"], "aes_key": _aes_key_b64(uploaded["aeskey_hex"]), "encrypt_type": 1, }, "mid_size": uploaded["filesize"], }, } def build_video_item(uploaded: dict) -> dict: """构造视频 MessageItem(type=5)。""" return { "type": 5, "video_item": { "media": { "encrypt_query_param": uploaded["download_param"], "aes_key": _aes_key_b64(uploaded["aeskey_hex"]), "encrypt_type": 1, }, "video_size": uploaded["filesize"], }, } # ============================================================ # 接收侧媒体下载解密(P2) # 参考 openclaw cdn/pic-decrypt.ts + media/media-download.ts # ============================================================ def parse_aes_key(aes_key_base64: str) -> bytes: """解析 CDNMedia.aes_key 为原始 16 字节 AES key。 两种真实编码(openclaw parseAesKey): - base64(raw 16 bytes) → 图片 (media.aes_key) - base64(hex 32 chars 字符串) → 文件/语音/视频 第二种需 base64 解码后得到 32 个 ASCII hex 字符,再 hex 解码成 16 字节。 """ decoded = base64.b64decode(aes_key_base64) if len(decoded) == 16: return decoded if len(decoded) == 32 and re.match(r"^[0-9a-fA-F]{32}$", decoded.decode("ascii", "ignore")): return bytes.fromhex(decoded.decode("ascii")) raise ValueError( f"aes_key 解码后应为 16 字节或 32 字符 hex,实际 {len(decoded)} 字节" ) def download_and_decrypt_media( encrypted_query_param: str, aes_key_base64: str, cdn_base_url: str = WECHAT_CDN_BASE, full_url: str = None, ) -> bytes: """下载并 AES-128-ECB 解密 CDN 媒体文件,返回明文字节。""" key = parse_aes_key(aes_key_base64) if full_url: url = full_url else: url = ( f"{cdn_base_url}/download" f"?encrypted_query_param={quote(encrypted_query_param, safe='')}" ) resp = requests.get(url, timeout=60) if not resp.ok: raise RuntimeError(f"CDN download failed HTTP {resp.status_code}: {resp.text[:150]}") encrypted = resp.content return decrypt_aes_ecb(encrypted, key) def extract_inbound_media(item: dict) -> tuple: """从入站 MessageItem 提取媒体并下载解密。 返回 (media_kind, media_bytes),不支持的类型或失败返回 (None, None)。 media_kind ∈ {"image", "voice", "file", "video"} """ try: t = item.get("type") if t == 2: # 图片 img = item.get("image_item") or {} media = img.get("media") or {} eqp = media.get("encrypt_query_param") or "" full = media.get("full_url") if not eqp and not full: return None, None # 图片 key 优先用 image_item.aeskey(hex string)转 base64 if img.get("aeskey"): aes_key_b64 = base64.b64encode(bytes.fromhex(img["aeskey"])).decode() else: aes_key_b64 = media.get("aes_key") or "" if aes_key_b64: return "image", download_and_decrypt_media(eqp, aes_key_b64, full_url=full) return "image", _download_plain(eqp, full) elif t == 3: # 语音 voice = item.get("voice_item") or {} media = voice.get("media") or {} eqp = media.get("encrypt_query_param") or "" full = media.get("full_url") aes_key_b64 = media.get("aes_key") or "" if not aes_key_b64: return None, None return "voice", download_and_decrypt_media(eqp, aes_key_b64, full_url=full) elif t == 4: # 文件 fitem = item.get("file_item") or {} media = fitem.get("media") or {} eqp = media.get("encrypt_query_param") or "" full = media.get("full_url") aes_key_b64 = media.get("aes_key") or "" if not aes_key_b64: return None, None return "file", download_and_decrypt_media(eqp, aes_key_b64, full_url=full) elif t == 5: # 视频 vitem = item.get("video_item") or {} media = vitem.get("media") or {} eqp = media.get("encrypt_query_param") or "" full = media.get("full_url") aes_key_b64 = media.get("aes_key") or "" if not aes_key_b64: return None, None return "video", download_and_decrypt_media(eqp, aes_key_b64, full_url=full) except Exception: return None, None return None, None def _download_plain(encrypted_query_param: str, full_url: str = None) -> bytes: """下载未加密的 CDN 媒体(无 aes_key 时)。""" if full_url: url = full_url else: url = ( f"{WECHAT_CDN_BASE}/download" f"?encrypted_query_param={quote(encrypted_query_param, safe='')}" ) resp = requests.get(url, timeout=60) if not resp.ok: raise RuntimeError(f"CDN plain download failed HTTP {resp.status_code}") return resp.content