Files
qihuaiflow/backend/python/app/api/im_ilink_media.py
T
qihu-bot 46e9f1e1e6
CI / gate (push) Has been cancelled
#969 微信Bot媒体内联发送 - iLink CDN AES加密上传
参考微信官方openclaw-weixin源码实现媒体内联发送链路:下载媒体→getuploadurl→AES-128-ECB加密→上传微信CDN→内联发送(type=2/5)。新增im_ilink_media.py模块。失败降级文本链接兜底。修复pg18/casdoor容器未启动问题。
2026-08-15 17:45:47 +08:00

215 lines
7.0 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
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"],
},
}