Files
hermes-agent/gateway/platforms/yuanbao_media.py
T

422 lines
16 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.
"""
yuanbao_media.py — 元宝平台媒体处理模块
提供 COS 上传、文件下载、TIM 媒体消息构建等功能。移植自 TypeScript 版 media.ts
(yuanbao-openclaw-plugin),使用 httpx 替代 cos-nodejs-sdk-v5。
COS 上传流程:
1. genUploadInfo 获取临时凭证(tmpSecretId/tmpSecretKey/sessionToken)
2. 临时凭证 HMAC-SHA1 签名构建 Authorization 头
3. HTTP PUT 上传到 COS
TIM 消息体:build_image_msg_body() → TIMImageElem,build_file_msg_body() → TIMFileElem
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import struct
import time
import urllib.parse
from typing import Optional, Any
import httpx
logger = logging.getLogger(__name__)
UPLOAD_INFO_PATH = "/api/resource/genUploadInfo"
DEFAULT_API_DOMAIN = "yuanbao.tencent.com"
DEFAULT_MAX_SIZE_MB = 50
# COS 加速域名(优先使用全球加速)
COS_USE_ACCELERATE = True
# MIME → image_format 数字(TIM 协议字段)
_MIME_TO_IMAGE_FORMAT: dict[str, int] = {
"image/jpeg": 1, "image/jpg": 1, "image/gif": 2, "image/png": 3, "image/bmp": 4,
"image/webp": 255, "image/heic": 255, "image/tiff": 255,
}
_EXT_TO_MIME: dict[str, str] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".heic": "image/heic",
".tiff": "image/tiff",
".ico": "image/x-icon",
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".txt": "text/plain",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".wav": "audio/wav",
".ogg": "audio/ogg",
".webm": "video/webm",
}
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff", ".ico"}
def guess_mime_type(filename: str) -> str:
"""根据文件扩展名猜测 MIME 类型。"""
return _EXT_TO_MIME.get(os.path.splitext(filename)[-1].lower(), "application/octet-stream")
def is_image(filename: str, mime_type: str = "") -> bool:
"""判断是否为图片类型(MIME 前缀或扩展名)。"""
return mime_type.startswith("image/") or os.path.splitext(filename)[-1].lower() in _IMAGE_EXTS
def get_image_format(mime_type: str) -> int:
"""获取 TIM 图片格式编号。"""
return _MIME_TO_IMAGE_FORMAT.get(mime_type.lower(), 255)
def md5_hex(data: bytes) -> str:
return hashlib.md5(data).hexdigest()
def generate_file_id() -> str:
"""随机文件 ID(32 位 hex)。"""
return secrets.token_hex(16)
# ============ 图片尺寸解析(纯 Python,无需 Pillow) ============
def parse_image_size(data: bytes) -> Optional[dict[str, int]]:
"""解析图片宽高(JPEG/PNG/GIF/WebP),返回 {"width", "height"} 或 None。"""
return _parse_png_size(data) or _parse_jpeg_size(data) or _parse_gif_size(data) or _parse_webp_size(data)
def _wh(w: int, h: int) -> dict[str, int]:
return {"width": w, "height": h}
def _parse_png_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 24 or buf[:4] != b"\x89PNG":
return None
return _wh(*struct.unpack(">II", buf[16:24]))
def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 4 or buf[0] != 0xFF or buf[1] != 0xD8:
return None
i = 2
while i < len(buf) - 9:
if buf[i] != 0xFF:
i += 1
continue
if buf[i + 1] in {0xC0, 0xC2}: # SOF0 / SOF2: height then width
h, w = struct.unpack(">HH", buf[i + 5: i + 9])
return _wh(w, h)
if i + 3 < len(buf):
i += 2 + struct.unpack(">H", buf[i + 2: i + 4])[0]
else:
break
return None
def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 10 or buf[:6].decode("ascii", errors="replace") not in {"GIF87a", "GIF89a"}:
return None
return _wh(*struct.unpack("<HH", buf[6:10]))
def _parse_webp_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 16 or buf[:4] != b"RIFF" or buf[8:12] != b"WEBP":
return None
chunk = buf[12:16].decode("ascii", errors="replace")
if chunk == "VP8 ":
if len(buf) >= 30 and buf[23:26] == b"\x9d\x01\x2a":
w, h = struct.unpack("<HH", buf[26:30])
return _wh(w & 0x3FFF, h & 0x3FFF)
elif chunk == "VP8L":
if len(buf) >= 25 and buf[20] == 0x2F:
bits = struct.unpack("<I", buf[21:25])[0]
return _wh((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)
elif chunk == "VP8X" and len(buf) >= 30:
return _wh(int.from_bytes(buf[24:27], "little") + 1, int.from_bytes(buf[27:30], "little") + 1)
return None
# ============ URL 下载 ============
async def download_url(url: str, max_size_mb: int = DEFAULT_MAX_SIZE_MB) -> tuple[bytes, str]:
"""下载 URL 内容,返回 (bytes, content_type)。
Raises:
ValueError: 内容超过 max_size_mb,或 URL / 重定向目标被 SSRF 防护拦截
httpx.HTTPError: 网络/HTTP 错误
"""
# SSRF protection: yuanbao downloads model-supplied and inbound URLs server-side.
# Reject private/internal targets up front, and re-validate every redirect hop so a
# public URL can't 302 to http://169.254.169.254/.
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}")
async def _redirect_guard(response: httpx.Response) -> None:
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError(f"Blocked redirect to private/internal address: {redirect_url}")
max_bytes = max_size_mb * 1024 * 1024
async with create_ssrf_safe_async_client(
timeout=30.0, follow_redirects=True, event_hooks={"response": [_redirect_guard]},
) as client:
# HEAD 预检大小;部分服务器不支持 HEAD,忽略
try:
head = await client.head(url)
content_length = int(head.headers.get("content-length", 0) or 0)
if content_length > 0 and content_length > max_bytes:
raise ValueError(f"文件过大: {content_length / 1024 / 1024:.1f} MB > {max_size_mb} MB")
except httpx.HTTPStatusError:
pass
# 流式 GET,防止超限
async with client.stream("GET", url) as resp:
resp.raise_for_status()
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
chunks: list[bytes] = []
downloaded = 0
async for chunk in resp.aiter_bytes(65536):
downloaded += len(chunk)
if downloaded > max_bytes:
raise ValueError(f"文件过大: 已超过 {max_size_mb} MB 限制")
chunks.append(chunk)
return b"".join(chunks), content_type
# ============ COS 鉴权(HMAC-SHA1) ============
def _hmac_sha1_hex(key: str, msg: str) -> str:
return hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha1).hexdigest()
def _sorted_kv(d: dict[str, str]) -> list[tuple[str, str]]:
"""签名用 (小写 key, URL-encoded value) 按字典序排列"""
return sorted((k.lower(), urllib.parse.quote(str(v), safe="")) for k, v in d.items())
def _cos_sign(
method: str,
path: str,
params: dict[str, str],
headers: dict[str, str],
secret_id: str,
secret_key: str,
start_time: Optional[int] = None,
expire_seconds: int = 3600,
) -> str:
"""构建 COS 请求签名(q-sign-algorithm=sha1;https://cloud.tencent.com/document/product/436/7778)。
Args:
method / path: HTTP 方法(小写)与 URL-encoded 路径
params / headers: 参与签名的查询参数与请求头(key 会被小写)
secret_id / secret_key: 临时 tmpSecretId / tmpSecretKey
start_time: 签名起始 Unix 时间戳(默认 now);expire_seconds: 有效期(秒)
Returns:
Authorization header 值
"""
now = int(time.time())
q_sign_time = f"{start_time or now};{(start_time or now) + expire_seconds}"
# SignKey = HMAC-SHA1(SecretKey, q-sign-time)
sign_key = _hmac_sha1_hex(secret_key, q_sign_time)
sorted_params = _sorted_kv(params)
sorted_headers = _sorted_kv(headers)
http_string = "\n".join([
method.lower(),
path,
"&".join(f"{k}={v}" for k, v in sorted_params),
"&".join(f"{k}={v}" for k, v in sorted_headers),
"",
])
string_to_sign = "\n".join(["sha1", q_sign_time, hashlib.sha1(http_string.encode("utf-8")).hexdigest(), ""])
signature = _hmac_sha1_hex(sign_key, string_to_sign)
return (
f"q-sign-algorithm=sha1"
f"&q-ak={secret_id}"
f"&q-sign-time={q_sign_time}"
f"&q-key-time={q_sign_time}"
f"&q-header-list={';'.join(k for k, _ in sorted_headers)}"
f"&q-url-param-list={';'.join(k for k, _ in sorted_params)}"
f"&q-signature={signature}"
)
# ============ 主要公开 API ============
async def get_cos_credentials(
app_key: str,
api_domain: str,
token: str,
filename: str = "file",
file_id: Optional[str] = None,
bot_id: str = "",
route_env: str = "",
) -> dict:
"""调用 genUploadInfo 获取 COS 临时密钥及上传配置。
Args:
app_key / bot_id: X-ID 头(bot_id 优先); api_domain: 如 https://bot.yuanbao.tencent.com
token: 当前有效签票 token(X-Token 头); file_id: 不传则自动生成
Returns:
接口 data dict:bucketName, region, location (COS key), encryptTmpSecretId,
encryptTmpSecretKey, encryptToken, startTime, expiredTime, resourceUrl, resourceID(可选)
Raises:
RuntimeError: 接口返回非 0 code 或 bucketName/location 缺失
"""
if file_id is None:
file_id = generate_file_id()
upload_url = f"{api_domain.rstrip('/')}{UPLOAD_INFO_PATH}"
headers = {"Content-Type": "application/json", "X-Token": token, "X-ID": bot_id or app_key, "X-Source": "web"}
if route_env:
headers["X-Route-Env"] = route_env
body = {"fileName": filename, "fileId": file_id, "docFrom": "localDoc", "docOpenId": ""}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(upload_url, json=body, headers=headers)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
code = result.get("code")
if code != 0 and code is not None:
raise RuntimeError(f"genUploadInfo 失败: code={code}, msg={result.get('msg', '')}")
data = result.get("data") or result
missing = [f for f in ["bucketName", "location"] if not data.get(f)]
if missing:
raise RuntimeError(f"genUploadInfo 返回字段不完整: 缺少字段 {missing}")
return data
async def upload_to_cos(
file_bytes: bytes,
filename: str,
content_type: str,
credentials: dict,
bucket: str,
region: str,
) -> dict:
"""用临时凭证 HMAC-SHA1 签名,httpx PUT 上传到 COS。
Args:
credentials: get_cos_credentials() 返回的 dict(encryptTmpSecretId/Key, encryptToken,
location, resourceUrl, startTime, expiredTime)
bucket / region: 如 chatbot-1234567890 / ap-guangzhou
Returns:
{url, uuid (内容 MD5), size, width?, height? (仅图片)}
Raises:
httpx.HTTPStatusError: COS 返回非 2xx
RuntimeError: credentials 字段缺失
"""
secret_id: str = credentials.get("encryptTmpSecretId", "")
secret_key: str = credentials.get("encryptTmpSecretKey", "")
session_token: str = credentials.get("encryptToken", "")
cos_key: str = credentials.get("location", "")
resource_url: str = credentials.get("resourceUrl", "")
start_time: Optional[int] = credentials.get("startTime")
expired_time: Optional[int] = credentials.get("expiredTime")
if not secret_id or not secret_key or not cos_key:
raise RuntimeError(
f"COS credentials 不完整: secretId={bool(secret_id)}, "
f"secretKey={bool(secret_key)}, location={bool(cos_key)}"
)
cos_host = f"{bucket}.cos.accelerate.myqcloud.com" if COS_USE_ACCELERATE else f"{bucket}.cos.{region}.myqcloud.com"
encoded_key = urllib.parse.quote(cos_key, safe="/").lstrip("/")
cos_url = f"https://{cos_host}/{encoded_key}"
if not content_type or content_type == "application/octet-stream":
content_type = guess_mime_type(filename) if is_image(filename) else "application/octet-stream"
file_uuid = md5_hex(file_bytes)
file_size = len(file_bytes)
now = int(time.time())
authorization = _cos_sign(
method="put",
path=f"/{encoded_key}",
params={},
headers={"host": cos_host, "content-type": content_type, "x-cos-security-token": session_token},
secret_id=secret_id,
secret_key=secret_key,
start_time=start_time if start_time else now,
expire_seconds=(expired_time - now) if expired_time and expired_time > now else 3600,
)
put_headers = {"Authorization": authorization, "Content-Type": content_type, "x-cos-security-token": session_token}
logger.info("COS PUT: bucket=%s region=%s key=%s size=%d mime=%s", bucket, region, cos_key, file_size, content_type)
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.put(cos_url, content=file_bytes, headers=put_headers)
resp.raise_for_status()
result: dict[str, Any] = {"url": resource_url or cos_url, "uuid": file_uuid, "size": file_size}
if content_type.startswith("image/"):
size_info = parse_image_size(file_bytes)
if size_info:
result["width"] = size_info["width"]
result["height"] = size_info["height"]
logger.info("COS 上传成功: url=%s size=%d", result["url"], file_size)
return result
# ============ TIM 媒体消息构建(https://cloud.tencent.com/document/product/269/2720) ============
def build_image_msg_body(
url: str,
uuid: Optional[str] = None,
filename: Optional[str] = None,
size: int = 0,
width: int = 0,
height: int = 0,
mime_type: str = "",
) -> list[dict]:
"""构建 TIMImageElem 消息体(可直接放入 msg_body)。uuid 缺省依次退到 filename / URL basename / "image"。"""
return [{
"msg_type": "TIMImageElem",
"msg_content": {
"uuid": uuid or filename or _basename_from_url(url) or "image",
"image_format": get_image_format(mime_type) if mime_type else 255,
"image_info_array": [
{"type": 1, "size": size, "width": width, "height": height, "url": url}, # type 1 = 原图
],
},
}]
def build_file_msg_body(url: str, filename: str, uuid: Optional[str] = None, size: int = 0) -> list[dict]:
"""构建 TIMFileElem 消息体(可直接放入 msg_body)。uuid 缺省使用 filename。"""
return [{
"msg_type": "TIMFileElem",
"msg_content": {"uuid": uuid or filename, "file_name": filename, "file_size": size, "url": url},
}]
def _basename_from_url(url: str) -> str:
try:
return os.path.basename(urllib.parse.urlparse(url).path)
except Exception:
return ""