Files
qihuaiflow/backend/python/app/api/im_ilink_media.py
T
qihu-bot 51d8df6e6a
CI / gate (push) Has been cancelled
#971 微信Bot P2 - 接收侧媒体下载解密
实现CDN媒体下载解密(parse_aes_key支持base64(raw16B)和base64(hex32)两种编码)+extract_inbound_media(图片/语音/文件/视频)。接收循环检测媒体消息下载解密保存到/tmp。图片已保存供后续vision模型接入。
2026-08-15 18:40:01 +08:00

331 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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