实现CDN媒体下载解密(parse_aes_key支持base64(raw16B)和base64(hex32)两种编码)+extract_inbound_media(图片/语音/文件/视频)。接收循环检测媒体消息下载解密保存到/tmp。图片已保存供后续vision模型接入。
This commit is contained in:
1 parent
e284d43cc7
commit
51d8df6e6a
2 files changed
+156
-1
No files matched your search
@@ -425,6 +425,42 @@ def _wechat_extract_text(msg: dict) -> str:
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_and_save_inbound_media(msg: dict) -> str:
|
||||||
|
"""检测入站媒体消息(图片/语音/文件/视频),下载解密保存,返回给 LLM 的提示文本。
|
||||||
|
|
||||||
|
无媒体或解密失败返回空字符串。图片已保存到本地,供后续 vision 模型接入。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.api.im_ilink_media import extract_inbound_media
|
||||||
|
import os as _os
|
||||||
|
for it in (msg.get("item_list") or []):
|
||||||
|
if it.get("type") in (2, 3, 4, 5):
|
||||||
|
kind, data = extract_inbound_media(it)
|
||||||
|
if kind and data:
|
||||||
|
_dir = "/tmp/wechat_inbound_media"
|
||||||
|
_os.makedirs(_dir, exist_ok=True)
|
||||||
|
_ext = {"image": "jpg", "voice": "silk", "file": "bin", "video": "mp4"}.get(kind, "bin")
|
||||||
|
_path = _os.path.join(_dir, "%d_%s.%s" % (int(time.time() * 1000), kind, _ext))
|
||||||
|
with open(_path, "wb") as _f:
|
||||||
|
_f.write(data)
|
||||||
|
_label = {"image": "图片", "voice": "语音", "file": "文件", "video": "视频"}.get(kind, kind)
|
||||||
|
try:
|
||||||
|
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
|
||||||
|
_f.write(_beijing_now().isoformat() + " %s saved=%s size=%d\n"
|
||||||
|
% (kind, _path, len(data)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "[用户发送了%s,已保存到 %s(%d 字节)]" % (_label, _path, len(data))
|
||||||
|
return ""
|
||||||
|
except Exception as _e:
|
||||||
|
try:
|
||||||
|
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
|
||||||
|
_f.write(_beijing_now().isoformat() + " MEDIA_EXTRACT_ERR %s\n" % (_e,))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _wechat_sender_id(m: dict) -> str:
|
def _wechat_sender_id(m: dict) -> str:
|
||||||
"""从入站消息中提取发送者 id(兼容多套字段名)。"""
|
"""从入站消息中提取发送者 id(兼容多套字段名)。"""
|
||||||
for k in ("from_user_id", "from_user_name", "from_username", "user_id",
|
for k in ("from_user_id", "from_user_name", "from_username", "user_id",
|
||||||
@@ -576,7 +612,10 @@ def _wechat_receiver_loop(account_id: int):
|
|||||||
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
|
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
|
||||||
content = _wechat_extract_text(m)
|
content = _wechat_extract_text(m)
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
# 入站媒体(图片/语音/文件/视频):下载解密保存,供 LLM 感知
|
||||||
|
content = _extract_and_save_inbound_media(m)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
sender = _wechat_sender_id(m)
|
sender = _wechat_sender_id(m)
|
||||||
ctx = _wechat_context_token(m)
|
ctx = _wechat_context_token(m)
|
||||||
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
|
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -212,3 +213,118 @@ def build_video_item(uploaded: dict) -> dict:
|
|||||||
"video_size": uploaded["filesize"],
|
"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
|
||||||
Reference in new issue
Block a user