生成模型:Claude Opus 4.6 (anthropic/claude-opus-4-6) Token 消耗:输入 ~58k tokens,输出 ~8k tokens(本节)
聊天机器人不只处理文字——用户会发送图片、音频、PDF 文档,Agent 也可能回复带有媒体附件的消息。OpenClaw 的媒体管道(src/media/)负责整个媒体生命周期:从远程获取、MIME 类型检测、本地存储、图像缩放与格式转换,到通过 HTTP 服务器对外提供临时下载链接。本节将逐一拆解这条管道上的每个环节。
当 Agent 在回复中输出 MEDIA: https://example.com/photo.jpg 这样的令牌,Gateway 需要将该 URL 指向的资源下载到本地。fetchRemoteMedia 是这条路径的入口函数:
// src/media/fetch.ts(简化)
export type FetchMediaResult = {
buffer: Buffer; // 文件二进制内容
contentType?: string; // 检测后的 MIME 类型
fileName?: string; // 推断出的文件名
};
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
// 1. SSRF 防护:通过 fetchWithSsrFGuard 发起请求
const { response, finalUrl, release } = await fetchWithSsrFGuard({
url: options.url,
fetchImpl: options.fetchImpl,
maxRedirects: options.maxRedirects,
policy: options.ssrfPolicy,
lookupFn: options.lookupFn,
});
// 2. HTTP 状态检查(非 2xx 抛出 MediaFetchError)
if (!response.ok) {
throw new MediaFetchError("http_error", `HTTP ${response.status}...`);
}
// 3. 预检 Content-Length,超限则提前拒绝
if (maxBytes && contentLength > maxBytes) {
throw new MediaFetchError("max_bytes", `content length exceeds maxBytes`);
}
// 4. 流式读取 + 实时字节限制
const buffer = maxBytes
? await readResponseWithLimit(response, maxBytes)
: Buffer.from(await response.arrayBuffer());
// 5. 文件名推断:Content-Disposition > URL 路径 > filePathHint
const fileName = headerFileName || fileNameFromUrl || filePathHint;
// 6. MIME 检测 + 扩展名补全
const contentType = await detectMime({ buffer, headerMime, filePath });
return { buffer, contentType, fileName };
}
衍生解释 — SSRF(Server-Side Request Forgery)
SSRF 是一种安全漏洞:攻击者让服务器代为请求内部网络资源(如 http://169.254.169.254/ 获取云元数据)。OpenClaw 通过 fetchWithSsrFGuard 进行 DNS 预解析,拒绝指向私有 IP 地址段(10.x、172.16-31.x、192.168.x)的请求,并限制重定向次数,防止通过 302 跳转绕过检查。
readResponseWithLimit 使用 ReadableStream 的 getReader() API 逐块读取响应体。每读入一块数据,累加字节计数器。一旦超过 maxBytes 阈值,立即调用 reader.cancel() 终止流传输并抛出 max_bytes 错误——这避免了先将整个大文件下载到内存再检查大小的浪费:
文件名的确定遵循三级优先级:
filename*=UTF-8''%E5%9B%BE%E7%89%87.png
https://cdn.example.com/img/photo.jpg → photo.jpg
Content-Disposition 头支持 RFC 5987 的 filename* 扩展编码格式(charset'language'encoded-value),parseContentDispositionFileName 会先尝试解码 filename* 参数,再退回到普通 filename。如果推断出的文件名缺少扩展名,还会根据检测到的 MIME 类型自动补全(如 image/jpeg → .jpg)。
Agent 在输出文本中嵌入形如 MEDIA: <url> 的令牌来指示 Gateway 附带媒体。splitMediaFromOutput 负责从 Agent 的原始输出中提取这些令牌,同时保留其余文本内容:
解析过程需要处理多种边界情况:
1. 围栏代码块保护:如果 MEDIA: 出现在 Markdown 的 ``` 围栏代码块内部,它是代码示例而非真实的媒体令牌。解析器先调用 parseFenceSpans 计算所有围栏区间,逐行扫描时检查当前行的字符偏移是否落在某个围栏区间内——如果是,则跳过提取。
2. URL 校验与清洗:提取到的候选值经过 cleanCandidate(去除包裹的引号、括号)和 isValidMedia 校验。合法的媒体源只有两种形式:
HTTP(S) URL:https://example.com/image.png
安全的相对路径:必须以 ./ 开头,且不含 ..(禁止目录遍历)
3. 含空格路径的回退策略:当令牌值看起来像本地路径(以 /、./、~/、file:// 开头)且包含空格时,解析器会尝试将整个值(而非按空格分割后的片段)作为单个路径来校验。
4. 语音标签检测:解析完毕后,还会调用 parseAudioTag 检查文本中是否包含 [[audio_as_voice]] 指令标签。这个标签告诉 Gateway 将音频以"语音消息气泡"(而非普通文件附件)的形式发送到聊天平台。
提取完所有 MEDIA: 行后,剩余文本会经过多轮清洗:
最终返回的 text 字段是干净的、可以直接发送给用户的纯文本/Markdown 内容。
媒体文件保存在 OpenClaw 配置目录下的 media/ 子目录中(通常是 ~/.config/openclaw/media/),由 ensureMediaDir 按需创建,权限设为 0o700(仅当前用户可访问)。
store.ts 提供两个存储入口:
saveMediaSource 的双路径处理:
saveMediaBuffer 的原始文件名嵌入:当接收入站媒体时,原始文件名通过 {sanitized}---{uuid}.{ext} 格式嵌入到存储文件名中。sanitizeFilename 会过滤掉跨平台不安全字符(只保留字母、数字、.、-、_),截断到 60 字符。反向提取时,extractOriginalFilename 通过正则匹配 ---{uuid} 模式还原原始名称。
所有媒体文件都是临时性的,默认 TTL 为 2 分钟。cleanOldMedia 在每次保存新文件时触发,遍历存储目录中的所有文件,删除修改时间超过 TTL 的过期文件:
downloadToFile 使用 Node.js 原生的 http.request / https.request(而非 fetch)进行下载,原因是需要精细控制流传输过程:
SSRF 防护:通过 resolvePinnedHostname 对目标主机名进行 DNS 预解析并锁定 IP(pinning),将解析结果传递给底层请求的 lookup 参数
手动重定向跟踪:最多 5 次重定向,每次解析 Location 头并递归调用
流式写入:使用 pipeline(res, createWriteStream(dest)) 将响应体直接流向磁盘
MIME 嗅探缓存:同时缓存前 16KB 数据到内存(sniffChunks),供后续 MIME 检测使用
大小限制:累计字节超过 5MB(MEDIA_MAX_BYTES)时调用 req.destroy() 终止连接
Sharp 与 sips 双后端
OpenClaw 的图像处理支持两种后端,根据运行环境自动选择:
macOS 内置的 Scriptable Image Processing System
衍生解释 — Sharp
Sharp 是 Node.js 生态中最流行的图像处理库,底层调用 C 库 libvips。它支持 JPEG、PNG、WebP、HEIC 等格式的读取、缩放、旋转、格式转换,性能远超纯 JavaScript 方案(如 Jimp)。但 Sharp 依赖原生二进制模块(.node 文件),在某些运行时(如 Bun)中可能存在兼容性问题。
衍生解释 — sips
sips(Scriptable Image Processing System)是 macOS 自带的命令行图像处理工具,位于 /usr/bin/sips。它通过 Core Image 框架支持 JPEG、PNG、HEIC 等格式的查询与转换。OpenClaw 在 Bun + macOS 组合下优先使用 sips 作为 Sharp 的替代方案,避免原生模块兼容性问题。
后端选择逻辑由 prefersSips 函数控制:
1. 获取图像元信息
sips 后端需要先将 Buffer 写入临时文件(通过 withTempDir 辅助函数创建临时目录,函数返回后自动清理),再调用命令行工具解析输出。
2. EXIF 方向矫正
JPEG 文件的 EXIF 元数据中包含拍摄方向信息(orientation,值 1-8),手机竖拍的照片在原始像素上可能是横向的。OpenClaw 实现了一套纯 JavaScript 的 EXIF 方向读取器 readJpegExifOrientation,直接解析 JPEG 二进制格式:
Sharp 后端更简单:调用 .rotate()(不带参数)即可根据 EXIF 自动旋转。
3. 图像缩放
resizeToJpeg 在缩放前先矫正 EXIF 方向,避免缩放后图像方向错误:
Sharp 使用 fit: "inside" 策略——将图像等比缩放到 maxSide × maxSide 的包围盒内,mozjpeg: true 启用 Mozilla JPEG 编码器以获得更好的压缩比。
4. PNG 优化
对于带透明通道(alpha)的图像,OpenClaw 使用 PNG 格式而非 JPEG。optimizeImageToPng 在一个尺寸-压缩级别的二维搜索网格上寻找满足大小限制的最优组合:
从最大尺寸开始尝试,找到第一个输出大小 ≤ maxBytes 的组合即返回。如果所有组合都超限,返回尝试过程中体积最小的那个结果。
5. HEIC 转换
音频模块相对轻量,主要负责判断音频文件是否兼容聊天平台的"语音消息"格式:
衍生解释 — OGG/Opus 格式
OGG 是一种开源的多媒体容器格式,Opus 是其中使用最广泛的音频编解码器。Telegram 等聊天平台的语音消息使用 OGG Opus 格式(.ogg 或 .oga 扩展名)。相比 MP3,Opus 在低码率下(如 16kbps)依然保持良好的语音质量,非常适合实时通信场景。
配合 parse.ts 中的 [[audio_as_voice]] 标签,整个音频语音流程为:
MIME 类型的准确检测是整个媒体管道的基石。detectMime 综合三种信息源进行判断:
衍生解释 — 魔数(Magic Number)
大多数文件格式在开头几个字节有固定的标识符,称为"魔数"。例如 JPEG 文件以 FF D8 开头,PNG 以 89 50 4E 47 开头。通过检查这些字节可以准确判断文件类型,不依赖文件扩展名。OpenClaw 使用 file-type 库实现魔数检测。
检测优先级:
有一个重要的特殊处理:当魔数嗅探返回"通用容器类型"(application/octet-stream 或 application/zip)时,扩展名映射会覆盖嗅探结果。这解决了一个常见问题——.xlsx(Excel 文件)的二进制格式本质上是 ZIP 压缩包,魔数检测会将其识别为 application/zip,而正确的 MIME 应该是 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。
constants.ts 定义了媒体的四种分类及其大小上限:
mediaKindFromMime 根据 MIME 类型将文件归入以上四类之一,maxBytesForKind 返回对应的大小上限。这些限制在媒体获取和存储的各个环节中被一致地执行。
存储到磁盘的媒体文件需要通过 HTTP 对外提供下载。attachMediaRoutes 在 Express 应用上挂载 GET /media/:id 路由:
关键的安全措施包括:
ID 格式白名单:正则 /^[\p{L}\p{N}._-]+$/u 确保不含路径分隔符
根目录锁定:openFileWithinRoot 解析符号链接后验证真实路径仍在 mediaDir 内部
410 Gone 响应:过期文件返回 HTTP 410 而非 404,符合 REST 语义
host.ts 的 ensureMediaHosted 函数编排整个托管流程:
衍生解释 — Tailscale
Tailscale 是一个基于 WireGuard 的零配置 VPN 网络(也称 Tailnet)。每个加入 Tailnet 的设备会获得一个唯一的主机名(如 my-server.tailnet-xxxx.ts.net)和 HTTPS 证书。OpenClaw 利用 Tailscale 的 Funnel 功能,让本地运行的媒体服务器可以被互联网上的聊天平台(如 Telegram Webhook)访问到。
除了 Agent 输出的出站媒体,OpenClaw 还需要处理用户发送的入站文件。input-files.ts 定义了完整的输入文件处理框架:
PDF 提取的双模式策略:
当输入文件为 PDF 时,提取器采用"文本优先,图像兜底"的策略:
如果提取到的文本超过阈值(默认 200 字符),认为 PDF 主要是文本型的,直接返回文本
如果文本过少(扫描件、纯图片 PDF),则加载 @napi-rs/canvas 将每页渲染为 PNG 图像
这两个依赖(pdfjs-dist 和 @napi-rs/canvas)都通过懒加载引入——只有真正处理 PDF 时才会 import(),避免非 PDF 场景的启动开销。
安全限制:
媒体获取通过 SSRF 防护的 fetch 拉取远程资源,支持流式字节限制和三级文件名推断。
媒体解析从 Agent 输出中提取 MEDIA: 令牌,处理围栏代码块、含空格路径、语音标签等边界情况。
媒体存储使用 UUID + 原始文件名的命名策略,所有文件默认 2 分钟 TTL,自动清理过期文件。
图像处理采用 Sharp / sips 双后端架构,覆盖元信息查询、EXIF 方向矫正、JPEG/PNG 缩放、HEIC 转换等操作。
MIME 检测综合魔数嗅探、扩展名映射、HTTP 头三种来源,通过优先级规则解决 ZIP 容器误判等常见问题。
媒体服务器提供临时 HTTP 端点,集成 Tailscale Funnel 对外暴露,文件在被下载一次后自动删除。
输入文件处理支持 base64 / URL 两种来源,PDF 提取采用文本优先、图像兜底的双模式策略。