26.2 媒体管道

生成模型:Claude Opus 4.6 (anthropic/claude-opus-4-6) Token 消耗:输入 ~58k tokens,输出 ~8k tokens(本节)


聊天机器人不只处理文字——用户会发送图片、音频、PDF 文档,Agent 也可能回复带有媒体附件的消息。OpenClaw 的媒体管道(src/media/)负责整个媒体生命周期:从远程获取、MIME 类型检测、本地存储、图像缩放与格式转换,到通过 HTTP 服务器对外提供临时下载链接。本节将逐一拆解这条管道上的每个环节。

26.2.1 媒体获取(src/media/fetch.ts

远程媒体拉取

当 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 防护

衍生解释 — 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 使用 ReadableStreamgetReader() API 逐块读取响应体。每读入一块数据,累加字节计数器。一旦超过 maxBytes 阈值,立即调用 reader.cancel() 终止流传输并抛出 max_bytes 错误——这避免了先将整个大文件下载到内存再检查大小的浪费:

文件名推断策略

文件名的确定遵循三级优先级:

优先级
来源
示例

1(最高)

Content-Disposition 响应头

filename*=UTF-8''%E5%9B%BE%E7%89%87.png

2

URL 路径的 basename

https://cdn.example.com/img/photo.jpgphoto.jpg

3(最低)

调用方传入的 filePathHint

用户消息中的原始路径

Content-Disposition 头支持 RFC 5987 的 filename* 扩展编码格式(charset'language'encoded-value),parseContentDispositionFileName 会先尝试解码 filename* 参数,再退回到普通 filename。如果推断出的文件名缺少扩展名,还会根据检测到的 MIME 类型自动补全(如 image/jpeg.jpg)。

26.2.2 媒体解析(src/media/parse.ts

MEDIA 令牌协议

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 内容。

26.2.3 媒体存储(src/media/store.ts

存储目录

媒体文件保存在 OpenClaw 配置目录下的 media/ 子目录中(通常是 ~/.config/openclaw/media/),由 ensureMediaDir 按需创建,权限设为 0o700(仅当前用户可访问)。

存储方式

store.ts 提供两个存储入口:

函数
输入
用途

saveMediaSource(source)

URL 或本地路径

Agent 输出的 MEDIA: 令牌指向的资源

saveMediaBuffer(buffer)

内存中的 Buffer

平台接收到的入站媒体(用户发送的图片等)

saveMediaSource 的双路径处理

saveMediaBuffer 的原始文件名嵌入:当接收入站媒体时,原始文件名通过 {sanitized}---{uuid}.{ext} 格式嵌入到存储文件名中。sanitizeFilename 会过滤掉跨平台不安全字符(只保留字母、数字、.-_),截断到 60 字符。反向提取时,extractOriginalFilename 通过正则匹配 ---{uuid} 模式还原原始名称。

TTL 清理机制

所有媒体文件都是临时性的,默认 TTL 为 2 分钟。cleanOldMedia 在每次保存新文件时触发,遍历存储目录中的所有文件,删除修改时间超过 TTL 的过期文件:

流式下载到磁盘

downloadToFile 使用 Node.js 原生的 http.request / https.request(而非 fetch)进行下载,原因是需要精细控制流传输过程:

  1. SSRF 防护:通过 resolvePinnedHostname 对目标主机名进行 DNS 预解析并锁定 IP(pinning),将解析结果传递给底层请求的 lookup 参数

  2. 手动重定向跟踪:最多 5 次重定向,每次解析 Location 头并递归调用

  3. 流式写入:使用 pipeline(res, createWriteStream(dest)) 将响应体直接流向磁盘

  4. MIME 嗅探缓存:同时缓存前 16KB 数据到内存(sniffChunks),供后续 MIME 检测使用

  5. 大小限制:累计字节超过 5MB(MEDIA_MAX_BYTES)时调用 req.destroy() 终止连接

26.2.4 图像处理:双后端架构(src/media/image-ops.ts

Sharp 与 sips 双后端

OpenClaw 的图像处理支持两种后端,根据运行环境自动选择:

后端
条件
技术

Sharp

默认后端(Node.js 环境)

基于 libvips 的高性能图像库

sips

Bun 运行时 + macOS

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 二进制格式:

方向值
变换
sips 参数

1

无变换

(跳过)

2

水平翻转

-f horizontal

3

旋转 180°

-r 180

6

顺时针旋转 90°

-r 90

8

顺时针旋转 270°

-r 270

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 转换

26.2.5 音频处理(src/media/audio.ts

音频模块相对轻量,主要负责判断音频文件是否兼容聊天平台的"语音消息"格式:

衍生解释 — OGG/Opus 格式

OGG 是一种开源的多媒体容器格式,Opus 是其中使用最广泛的音频编解码器。Telegram 等聊天平台的语音消息使用 OGG Opus 格式(.ogg.oga 扩展名)。相比 MP3,Opus 在低码率下(如 16kbps)依然保持良好的语音质量,非常适合实时通信场景。

配合 parse.ts 中的 [[audio_as_voice]] 标签,整个音频语音流程为:

26.2.6 MIME 类型检测(src/media/mime.ts

三源检测策略

MIME 类型的准确检测是整个媒体管道的基石。detectMime 综合三种信息源进行判断:

衍生解释 — 魔数(Magic Number)

大多数文件格式在开头几个字节有固定的标识符,称为"魔数"。例如 JPEG 文件以 FF D8 开头,PNG 以 89 50 4E 47 开头。通过检查这些字节可以准确判断文件类型,不依赖文件扩展名。OpenClaw 使用 file-type 库实现魔数检测。

检测优先级:

步骤
来源
说明

1

魔数嗅探(fileTypeFromBuffer

最可靠,但无法识别纯文本格式

2

扩展名映射(MIME_BY_EXT

覆盖 35+ 常见格式

3

HTTP Content-Type 头

可能不准确,优先级最低

有一个重要的特殊处理:当魔数嗅探返回"通用容器类型"(application/octet-streamapplication/zip)时,扩展名映射会覆盖嗅探结果。这解决了一个常见问题——.xlsx(Excel 文件)的二进制格式本质上是 ZIP 压缩包,魔数检测会将其识别为 application/zip,而正确的 MIME 应该是 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

类型分类与大小限制

constants.ts 定义了媒体的四种分类及其大小上限:

分类
MIME 前缀
最大字节

image

image/*

6 MB

audio

audio/*

16 MB

video

video/*

16 MB

document

application/*(含 PDF)

100 MB

mediaKindFromMime 根据 MIME 类型将文件归入以上四类之一,maxBytesForKind 返回对应的大小上限。这些限制在媒体获取和存储的各个环节中被一致地执行。

26.2.7 媒体服务器(src/media/server.tssrc/media/host.ts

Express 媒体路由

存储到磁盘的媒体文件需要通过 HTTP 对外提供下载。attachMediaRoutes 在 Express 应用上挂载 GET /media/:id 路由:

关键的安全措施包括:

  • ID 格式白名单:正则 /^[\p{L}\p{N}._-]+$/u 确保不含路径分隔符

  • 根目录锁定openFileWithinRoot 解析符号链接后验证真实路径仍在 mediaDir 内部

  • 410 Gone 响应:过期文件返回 HTTP 410 而非 404,符合 REST 语义

媒体托管编排

host.tsensureMediaHosted 函数编排整个托管流程:

衍生解释 — Tailscale

Tailscale 是一个基于 WireGuard 的零配置 VPN 网络(也称 Tailnet)。每个加入 Tailnet 的设备会获得一个唯一的主机名(如 my-server.tailnet-xxxx.ts.net)和 HTTPS 证书。OpenClaw 利用 Tailscale 的 Funnel 功能,让本地运行的媒体服务器可以被互联网上的聊天平台(如 Telegram Webhook)访问到。

26.2.8 输入文件处理(src/media/input-files.ts

入站文件提取

除了 Agent 输出的出站媒体,OpenClaw 还需要处理用户发送的入站文件。input-files.ts 定义了完整的输入文件处理框架:

PDF 提取的双模式策略

当输入文件为 PDF 时,提取器采用"文本优先,图像兜底"的策略:

  1. 使用 pdfjs-dist 逐页提取文本内容

  2. 如果提取到的文本超过阈值(默认 200 字符),认为 PDF 主要是文本型的,直接返回文本

  3. 如果文本过少(扫描件、纯图片 PDF),则加载 @napi-rs/canvas 将每页渲染为 PNG 图像

这两个依赖(pdfjs-dist@napi-rs/canvas)都通过懒加载引入——只有真正处理 PDF 时才会 import(),避免非 PDF 场景的启动开销。

安全限制

参数
默认值
说明

maxBytes

5 MB(文件)/ 10 MB(图片)

文件大小上限

maxChars

200,000

提取文本的字符上限

maxPages

4

PDF 最大处理页数

maxPixels

4,000,000

PDF 页面渲染的最大像素数

maxRedirects

3

URL 来源的最大重定向次数

timeoutMs

10,000

URL 获取超时


本节小结

  1. 媒体获取通过 SSRF 防护的 fetch 拉取远程资源,支持流式字节限制和三级文件名推断。

  2. 媒体解析从 Agent 输出中提取 MEDIA: 令牌,处理围栏代码块、含空格路径、语音标签等边界情况。

  3. 媒体存储使用 UUID + 原始文件名的命名策略,所有文件默认 2 分钟 TTL,自动清理过期文件。

  4. 图像处理采用 Sharp / sips 双后端架构,覆盖元信息查询、EXIF 方向矫正、JPEG/PNG 缩放、HEIC 转换等操作。

  5. MIME 检测综合魔数嗅探、扩展名映射、HTTP 头三种来源,通过优先级规则解决 ZIP 容器误判等常见问题。

  6. 媒体服务器提供临时 HTTP 端点,集成 Tailscale Funnel 对外暴露,文件在被下载一次后自动删除。

  7. 输入文件处理支持 base64 / URL 两种来源,PDF 提取采用文本优先、图像兜底的双模式策略。

Last updated