Skip to content

OpenClaw 源码解读(七)媒体处理系统

本文基于 OpenClaw 2026.3.2 源码,深入解读媒体处理系统的架构设计与实现细节。


一、模块概览

媒体处理系统负责 OpenClaw 中所有多媒体内容的接收、存储、检测、转码、服务和生成。它包含三大子系统:媒体存储管线(接收/下载/存储)、TTS 语音合成(文字转语音)、图片理解/生成(VLM 视觉模型集成)。

1.1 核心源码分布

目录/文件行数(约)职责
src/media/store.ts~330+媒体存储核心(下载/保存/清理/ID 生成)
src/media/server.ts~117媒体 HTTP 服务器(Express 静态文件服务)
src/media/fetch.ts~191远程媒体拉取(SSRF 防护 + 重定向跟踪)
src/media/mime.ts~100+MIME 类型检测(三层检测链)
src/media/constants.ts~30+媒体常量(大小限制、目录、文件权限)
src/media/audio.ts~48音频工具(ffmpeg 检测 + 转码)
src/media/read-response-with-limit.ts~90带大小限制的响应体读取
src/tts/tts.ts~600+TTS 配置解析与偏好管理
src/tts/tts-core.ts~400+TTS 核心引擎(多 Provider 调度)
src/agents/tools/image-tool.ts~300+图片工具(VLM 视觉模型调用)
src/agents/tools/image-tool.helpers.ts~88图片工具辅助函数
src/agents/tools/tts-tool.ts~100+TTS Agent 工具封装
src/agents/tools/media-tool-shared.ts~200+媒体工具共享基础设施

1.2 系统全景

![媒体处理系统全景架构](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/01-infographic-media-system-overview-1775150629890.png)

┌───────────────────────────────────────────────────────────────────────┐
│                      媒体处理系统全景                                  │
│                                                                       │
│  ┌─────────────────────────────────────────────────────────────────┐  │
│  │                    入站媒体管线                                  │  │
│  │                                                                  │  │
│  │  用户发送图片/音频/视频/文件                                     │  │
│  │    │                                                             │  │
│  │    ▼                                                             │  │
│  │  渠道 SDK 接收 Buffer                                            │  │
│  │    │                                                             │  │
│  │    ▼                                                             │  │
│  │  saveMediaBuffer() ──→ MIME 检测 ──→ 文件名生成 ──→ 写入磁盘    │  │
│  │    │                                                             │  │
│  │    ▼                                                             │  │
│  │  返回 SavedMedia { id, path, contentType }                       │  │
│  └─────────────────────────────────────────────────────────────────┘  │
│                                                                       │
│  ┌─────────────────────────────────────────────────────────────────┐  │
│  │                    出站媒体管线                                  │  │
│  │                                                                  │  │
│  │  Agent 回复含媒体 URL                                            │  │
│  │    │                                                             │  │
│  │    ▼                                                             │  │
│  │  saveMediaSource()                                               │  │
│  │    ├── URL → downloadToFile() → MIME 检测 → 重命名             │  │
│  │    └── 本地路径 → readLocalFileSafely() → 复制                  │  │
│  │    │                                                             │  │
│  │    ▼                                                             │  │
│  │  Media Server 提供 HTTP URL                                      │  │
│  │    │                                                             │  │
│  │    ▼                                                             │  │
│  │  渠道出站发送(附带媒体 URL)                                    │  │
│  └─────────────────────────────────────────────────────────────────┘  │
│                                                                       │
│  ┌──────────────────────┐  ┌──────────────────────┐                  │
│  │    TTS 语音合成       │  │   图片理解/生成       │                  │
│  │                       │  │                       │                  │
│  │  3 Provider:          │  │  多 Provider VLM:     │                  │
│  │  ├── Edge TTS(免费) │  │  ├── Anthropic Claude │                  │
│  │  ├── OpenAI TTS      │  │  ├── OpenAI GPT-4V    │                  │
│  │  └── ElevenLabs      │  │  ├── Google Gemini    │                  │
│  │                       │  │  ├── MiniMax VL       │                  │
│  │  自动/手动/标签触发   │  │  └── 自定义 Provider  │                  │
│  │  用户偏好持久化       │  │                       │                  │
│  │  文本摘要化压缩       │  │  智能 Fallback 链     │                  │
│  └──────────────────────┘  └──────────────────────┘                  │
│                                                                       │
│  ┌─────────────────────────────────────────────────────────────────┐  │
│  │                    Media Server(媒体服务器)                     │  │
│  │  Express 静态文件服务  127.0.0.1:<port>                          │  │
│  │  TTL 自动清理(默认 2 小时)                                     │  │
│  │  nosniff / no-cache 安全头                                       │  │
│  └─────────────────────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────────────────┘

二、媒体存储——store.ts

![媒体存储双路径管线](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/02-infographic-storage-pipeline-1775150630715.png)

2.1 两种保存入口

typescript
// 入口 1: 保存远程 URL 或本地路径的媒体
export async function saveMediaSource(
  source: string,                            // URL 或本地路径
  headers?: Record<string, string>,          // HTTP 请求头(用于下载)
  subdir = "",                               // 子目录
): Promise<SavedMedia>

// 入口 2: 保存内存中的 Buffer
export async function saveMediaBuffer(
  buffer: Buffer,                            // 媒体数据
  contentType?: string,                      // 提示的 Content-Type
  subdir = "inbound",                        // 子目录(默认 inbound/)
  maxBytes = MAX_BYTES,                      // 大小限制
  originalFilename?: string,                 // 原始文件名(嵌入到 ID 中)
): Promise<SavedMedia>

返回值:

typescript
type SavedMedia = {
  id: string;           // 唯一文件名(UUID + 扩展名)
  path: string;         // 磁盘上的绝对路径
  size: number;         // 文件大小(字节)
  contentType?: string; // 检测到的 MIME 类型
};

2.2 saveMediaSource 流程

source 参数

  ├── 匹配 /^https?:\/\// → URL 模式
  │     │
  │     ├── 生成临时文件路径(UUID.tmp)
  │     ├── downloadToFile(url, tempDest, headers, maxRedirects=5)
  │     │     ├── 递归跟踪重定向(最多 5 次)
  │     │     ├── SSRF 防护:resolvePinnedHostnameImpl()
  │     │     ├── 协议检查:只允许 http/https
  │     │     ├── 大小限制:>5MB 立即中断
  │     │     ├── Sniff Buffer:前 16KB 用于 MIME 嗅探
  │     │     └── 流式写入磁盘
  │     │
  │     ├── detectMime({ buffer: sniffBuffer, headerMime, filePath })
  │     ├── 生成最终文件名:UUID + 扩展名
  │     └── rename(tempDest, finalDest)  ← 原子重命名

  └── 否则 → 本地路径模式

        ├── readLocalFileSafely({ filePath, maxBytes })
        │     ├── 符号链接检查
        │     ├── 路径遍历检查
        │     ├── 大小限制检查
        │     └── 路径一致性检查(防 TOCTOU)

        ├── detectMime({ buffer, filePath })
        └── writeFile(dest, buffer)

2.3 文件命名策略

![媒体存储设计细节:命名、TTL清理与安全常量](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/03-infographic-storage-details-1775150631575.png)

typescript
// 有原始文件名时:{sanitized}---{uuid}.ext
id = `${sanitizedOriginalName}---${uuid}${ext}`;
// 例:holiday-photo---550e8400-e29b-41d4-a716.jpg

// 无原始文件名时:{uuid}.ext
id = `${uuid}${ext}`;
// 例:550e8400-e29b-41d4-a716.jpg

嵌入原始文件名的好处:

  • 调试时一眼看出文件来源
  • 用户端看到可读的文件名
  • UUID 保证全局唯一性

2.4 TTL 自动清理

typescript
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;  // 2 小时

export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS): Promise<void> {
  const mediaDir = resolveMediaDir();
  const entries = await fs.readdir(mediaDir).catch(() => []);
  const now = Date.now();

  await Promise.all(entries.map(async (file) => {
    const full = path.join(mediaDir, file);
    const stat = await fs.stat(full).catch(() => null);
    if (!stat) return;

    if (stat.isDirectory()) {
      // 递归清理子目录
      await removeExpiredFilesInDir(full);
      return;
    }
    if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
      await fs.rm(full).catch(() => {});  // 安静删除
    }
  }));
}

清理时机:每次 saveMediaSource() 调用时触发,确保磁盘不会无限增长。

2.5 安全常量

typescript
// src/media/constants.ts
export const MAX_BYTES = 5 * 1024 * 1024;        // 5MB 最大文件大小
export const MEDIA_FILE_MODE = 0o600;             // 仅 owner 可读写
export const MEDIA_GALLERY_MAX_COUNT = 20;        // 图库最大张数
export const MEDIA_GALLERY_MAX_BYTES = 15 * 1024 * 1024;  // 图库总大小 15MB

三、MIME 类型检测——三层检测链

![MIME 三层检测链](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/04-infographic-mime-detection-chain-1775150632471.png)

3.1 检测流程

typescript
// src/media/mime.ts
export async function detectMime(params: {
  buffer: Buffer;           // 文件前 N 字节
  headerMime?: string | null;  // HTTP Content-Type 头
  filePath?: string;        // 文件路径(提取扩展名)
}): Promise<string | null>

三层优先级:

Layer 1: Magic Bytes 检测(file-type 库)
  │ → 通过二进制签名精确识别
  │ → JPEG: FF D8 FF | PNG: 89 50 4E 47 | ...

  ├── 成功 → 返回精确类型

  └── 失败 ↓

Layer 2: HTTP Content-Type 头
  │ → 从服务器返回的 Content-Type 提取
  │ → 过滤明显错误(如 text/html 伪装图片)

  ├── 有效且非通配符 → 返回

  └── 失败/无效 ↓

Layer 3: 文件扩展名推断
  │ → 从 URL 路径或本地文件名提取 .ext
  │ → 查找扩展名→MIME 映射表

  ├── 匹配 → 返回

  └── 全部失败 → 返回 null

3.2 扩展名↔MIME 映射

typescript
export function extensionForMime(mime?: string): string | undefined {
  // 标准映射
  // image/jpeg → .jpg
  // image/png → .png
  // image/webp → .webp
  // audio/mpeg → .mp3
  // audio/ogg → .ogg
  // video/mp4 → .mp4
  // application/pdf → .pdf
  // ...
}

四、远程媒体拉取——SSRF 防护

![SSRF 防护链四层防御](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/05-infographic-ssrf-protection-1775150633217.png)

4.1 fetchRemoteMedia

typescript
// src/media/fetch.ts
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult>

4.2 SSRF 防护链

typescript
const result = await fetchWithSsrFGuard(
  withStrictGuardedFetchMode({
    url,
    fetchImpl,
    init: requestInit,
    maxRedirects,
    policy: ssrfPolicy,     // SSRF 策略(严格/宽松/关闭)
    lookupFn,               // DNS 查找函数(用于 IP 检查)
  }),
);

SSRF(Server-Side Request Forgery)防护:

  • DNS PinningresolvePinnedHostnameImpl() 将域名解析为 IP 并锁定,防止 DNS rebinding
  • IP 过滤:拒绝请求内部网络地址(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • 协议限制:只允许 HTTP/HTTPS
  • 重定向限制:最多跟踪 maxRedirects 次重定向

4.3 错误分类

typescript
export class MediaFetchError extends Error {
  code: MediaFetchErrorCode;
}

type MediaFetchErrorCode =
  | "fetch_failed"    // 网络请求失败
  | "http_error"      // HTTP 错误状态码
  | "max_bytes"       // 超过大小限制
  | "ssrf_blocked";   // SSRF 防护阻断

4.4 Content-Disposition 文件名提取

typescript
function parseContentDispositionFileName(header: string | null): string | undefined {
  // RFC 6266 解析
  // Content-Disposition: attachment; filename="photo.jpg"
  // Content-Disposition: attachment; filename*=UTF-8''%E7%85%A7%E7%89%87.jpg
}

支持标准 filename 和 RFC 5987 编码的 filename* 两种格式。


五、媒体 HTTP 服务器

![媒体服务器与流式读取](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/06-infographic-media-server-1775150634130.png)

5.1 服务器架构

typescript
// src/media/server.ts
export function attachMediaRoutes(app: Express, ttlMs: number, runtime: RuntimeEnv) {
  app.use(
    "/media",
    express.static(resolveMediaDir(), {
      setHeaders: (res) => {
        res.setHeader("X-Content-Type-Options", "nosniff");   // 防 MIME 嗅探攻击
        res.setHeader("Cache-Control", "no-cache, no-store");  // 禁止缓存
      },
    }),
  );
}

export async function startMediaServer(
  port: number,
  ttlMs = DEFAULT_TTL_MS,
): Promise<Server> {
  const app = express();
  attachMediaRoutes(app, ttlMs);
  return await new Promise((resolve) => {
    const server = app.listen(port, "127.0.0.1");  // 仅 localhost
    server.once("listening", () => resolve(server));
  });
}

5.2 安全头

作用
X-Content-Type-Optionsnosniff防止浏览器猜测 MIME 类型
Cache-Controlno-cache, no-store敏感媒体不缓存

5.3 绑定地址

服务器只监听 127.0.0.1(localhost),不对外暴露。外部访问通过 Gateway 的反向代理实现。


六、带大小限制的响应体读取

typescript
// src/media/read-response-with-limit.ts
export async function readResponseWithLimit(
  res: Response,
  maxBytes: number,
  callbacks?: { onOverflow?: (params) => Error },
): Promise<Buffer>

工作原理:

HTTP 响应体(流式读取)

  ├── 每个 chunk 追加到 chunks 数组
  ├── 累加 totalBytes

  ├── totalBytes > maxBytes ?
  │     ├── 是 → 调用 reader.cancel()
  │     │        → 抛出 onOverflow 错误
  │     └── 否 → 继续读取

  └── 流结束 → Buffer.concat(chunks)

关键设计:流式读取 + 累加计数,避免一次性将超大响应加载到内存。


七、音频工具

typescript
// src/media/audio.ts
export async function hasFfmpegAvailable(): Promise<boolean> {
  try {
    const { exitCode } = await execFile("ffmpeg", ["-version"]);
    return exitCode === 0;
  } catch {
    return false;
  }
}

export async function transcodeAudio(params: {
  input: string;      // 输入文件路径
  output: string;     // 输出文件路径
  format?: string;    // 输出格式(如 "ogg")
  sampleRate?: number; // 采样率
}): Promise<void> {
  const args = ["-i", params.input, "-y"];
  if (params.format) args.push("-f", params.format);
  if (params.sampleRate) args.push("-ar", String(params.sampleRate));
  args.push(params.output);
  await execFile("ffmpeg", args);
}

hasFfmpegAvailable() 在运行时检测系统是否安装了 ffmpeg,用于决定是否启用音频转码功能。


八、TTS 语音合成——三 Provider 架构

![TTS 语音合成三 Provider 架构](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/07-infographic-tts-architecture-1775150634930.png)

8.1 三种 TTS Provider

Provider特点认证
Edge TTS微软 Edge 浏览器内置 TTS,免费无需 API Key
OpenAI TTS高质量,支持多种声音OPENAI_API_KEY
ElevenLabs最高质量,声音克隆ELEVENLABS_API_KEY

8.2 TTS 配置解析

typescript
// src/tts/tts.ts
export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig {
  return {
    auto: "off" | "always" | "inbound" | "tagged",  // 自动合成策略
    mode: "final" | "stream",                         // 合成时机
    provider: "edge" | "openai" | "elevenlabs",       // Provider
    summaryModel: string | undefined,                 // 摘要用模型
    modelOverrides: ResolvedTtsModelOverrides,         // 用户覆盖权限

    elevenlabs: {
      apiKey: string;
      baseUrl: string;
      voiceId: string;       // 默认 "EXAVITQu4vr4xnSDxMaL"
      modelId: string;       // 默认 "eleven_multilingual_v2"
      seed?: number;
      applyTextNormalization?: "auto" | "on" | "off";
      languageCode?: string;
      voiceSettings: {
        stability: number;        // 0.5
        similarityBoost: number;  // 0.75
        style: number;            // 0
        useSpeakerBoost: boolean; // true
        speed: number;            // 1
      },
    },

    openai: {
      apiKey: string;
      model: string;   // 默认 "tts-1"
      voice: string;   // 默认 "alloy"
    },

    edge: {
      enabled: boolean;
      voice: string;           // 默认 "en-US-AriaNeural"
      lang: string;            // 默认 "en-US"
      outputFormat: string;    // 默认 "audio-24khz-48kbitrate-mono-mp3"
      pitch?: string;
      rate?: string;
      volume?: string;
      saveSubtitles: boolean;
      proxy?: string;
      timeoutMs?: number;
    },

    maxTextLength: number,     // 默认 4096
    timeoutMs: number,         // 默认 60000
  };
}

8.3 四种自动合成模式

模式行为
off关闭 TTS
always所有回复自动合成语音
inbound仅当用户消息包含音频时才合成
tagged仅当 Agent 使用 /tts 标签时才合成

8.4 三级偏好优先级

Session 级偏好 → 当前会话临时设置
  │ (未设置)

用户偏好文件 → ~/.openclaw/settings/tts.json
  │ (未设置)

配置文件 → openclaw.json → messages.tts.auto

用户可以在对话中通过命令切换 TTS 模式,这些偏好存储在 JSON 文件中持久化。

8.5 模型覆盖权限控制

用户可以在消息中指定 TTS 参数覆盖(如选择不同的声音),但管理员可以限制:

typescript
type ResolvedTtsModelOverrides = {
  enabled: boolean;          // 总开关
  allowText: boolean;        // 允许覆盖合成文本
  allowProvider: boolean;    // 允许切换 Provider(默认关闭!)
  allowVoice: boolean;       // 允许选择声音
  allowModelId: boolean;     // 允许选择模型
  allowVoiceSettings: boolean; // 允许调整声音参数
  allowNormalization: boolean; // 允许调整文本规范化
  allowSeed: boolean;        // 允许设置随机种子
};

allowProvider 默认关闭——因为切换 Provider 可能产生额外费用。

8.6 系统提示词注入

typescript
export function buildTtsSystemPromptHint(cfg: OpenClawConfig): string | undefined {
  const autoMode = resolveTtsAutoMode({ ... });
  if (autoMode === "off") return undefined;  // TTS 关闭时不注入

  // 生成类似这样的提示:
  // "TTS is active (mode: always, max: 4096 chars, summarize: on).
  //  When replying, keep responses concise for voice output."
}

这让 Agent 意识到 TTS 已开启,从而调整回复风格(更简洁、更口语化)。


九、图片理解工具——VLM 视觉模型

![VLM 视觉模型选择与 Fallback 链](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/08-infographic-vlm-model-selection-1775150635901.png)

9.1 模型选择策略

typescript
export function resolveImageModelConfigForTool(params: {
  cfg?: OpenClawConfig;
  agentDir: string;
}): ImageModelConfig | null

选择优先级:

显式配置 → agents.defaults.imageModel
  │ (未配置)

智能配对 → 基于主模型 Provider 自动选择视觉模型

  ├── MiniMax → minimax/MiniMax-VL-01
  ├── ZAI → zai/glm-4.6v
  ├── OpenAI → openai/gpt-5-mini
  ├── Anthropic → anthropic/claude-opus-4-6
  ├── Google → google/gemini-2.5-flash

  └── Provider 模型配置中有 input: ["image"] 的模型
        → 优先使用已配置的视觉模型

9.2 多模型 Fallback

typescript
const fallbacks: string[] = [];

// 1. 同 Provider 的视觉模型
if (providerOk && providerVisionFromConfig) addFallback(providerVisionFromConfig);

// 2. Anthropic Claude(如果有 Key)
if (anthropicOk) {
  addFallback("anthropic/claude-opus-4-6");
  addFallback("anthropic/claude-opus-4-5");
}

// 3. OpenAI GPT(如果有 Key)
if (openaiOk) addFallback("openai/gpt-5-mini");

// 4. Google Gemini(如果有 Key)
if (googleOk) addFallback("google/gemini-2.5-flash");

Fallback 链只包含有对应 API Key 的 Provider,避免尝试无法认证的模型。

9.3 Data URL 解码

typescript
export function decodeDataUrl(dataUrl: string): {
  buffer: Buffer;
  mimeType: string;
  kind: "image";
} {
  const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(trimmed);
  if (!match) throw new Error("Invalid data URL");
  const mimeType = match[1].trim().toLowerCase();
  if (!mimeType.startsWith("image/")) throw new Error(`Unsupported: ${mimeType}`);
  return { buffer: Buffer.from(match[2], "base64"), mimeType, kind: "image" };
}

9.4 Token 限制控制

typescript
function resolveImageToolMaxTokens(
  modelMaxTokens: number | undefined,
  requestedMaxTokens = 4096,
): number {
  if (!modelMaxTokens || !Number.isFinite(modelMaxTokens) || modelMaxTokens <= 0) {
    return requestedMaxTokens;
  }
  return Math.min(requestedMaxTokens, modelMaxTokens);
}

十、媒体工具共享基础设施

10.1 认证解析

typescript
// src/agents/tools/media-tool-shared.ts
export function resolveModelRuntimeApiKey(params: {
  provider: string;
  agentDir: string;
  envApiKey?: string;
}): string | null

按优先级查找 API Key:

  1. Auth Profile Store 中的 Key
  2. 环境变量中的 Key
  3. 自定义 Provider 配置中的 Key

10.2 模型注册表发现

typescript
export function resolveModelFromRegistry(params: {
  modelRef: string;        // "openai/gpt-5-mini"
  cfg?: OpenClawConfig;
}): { provider: string; model: string; ... } | null

从配置的 models.providers 中查找模型元数据(上下文窗口、支持的输入类型等)。

10.3 本地文件根目录解析

typescript
export function resolveMediaToolLocalRoots(params: {
  workspaceDir?: string;
  cfg?: OpenClawConfig;
}): string[]

确定媒体文件的允许读取范围(仅工作区目录),防止路径遍历攻击。


十一、完整数据流

![三条完整数据流:入站、出站、TTS](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/09-infographic-data-flows-1775150636751.png)

11.1 入站媒体处理流

用户发送图片/音频/文件


渠道 SDK 接收原始 Buffer
  ├── Telegram: ctx.getFile() → download
  ├── Discord: attachment.url → fetch
  ├── WhatsApp: mediaId → download
  └── Signal: attachments → decrypt


saveMediaBuffer(buffer, contentType, "inbound", maxBytes, originalFilename)

  ├── 大小检查(> 5MB → 拒绝)
  ├── mkdir(mediaDir/inbound/, { mode: 0o700 })
  ├── detectMime({ buffer, headerMime: contentType })
  │     ├── Layer 1: fileTypeFromBuffer(buffer)  → Magic Bytes
  │     ├── Layer 2: headerMime  → Content-Type 头
  │     └── Layer 3: extensionForMime  → 文件扩展名
  ├── 生成 ID: {sanitized}---{uuid}.{ext}
  └── writeFile(dest, buffer, { mode: 0o600 })


SavedMedia { id, path, size, contentType }


构建 Agent 消息(附带媒体路径/URL)


Agent 处理
  ├── 图片 → 自动注入到 prompt images(VLM 支持时)
  ├── 音频 → 可选 STT 转录
  └── 文件 → 作为附件引用

11.2 出站媒体发送流

Agent 回复(含媒体)

  ├── 图片生成工具 → 返回 URL
  ├── TTS 合成 → 返回音频文件路径
  └── 文件引用 → 本地路径


saveMediaSource(source)

  ├── URL 模式:
  │     ├── downloadToFile(url, tempDest)
  │     │     ├── DNS pinning(SSRF 防护)
  │     │     ├── 协议检查(HTTP/HTTPS only)
  │     │     ├── 重定向跟踪(最多 5 次)
  │     │     ├── 大小限制(5MB)
  │     │     └── Sniff Buffer(前 16KB)
  │     ├── detectMime()
  │     └── rename(temp → final)  ← 原子操作

  └── 本地路径模式:
        ├── readLocalFileSafely()
        │     ├── 符号链接检查
        │     ├── 路径遍历检查
        │     └── TOCTOU 防护
        ├── detectMime()
        └── writeFile()


SavedMedia


Media Server → http://127.0.0.1:PORT/media/{id}


渠道出站适配器
  ├── Telegram: sendPhoto / sendAudio / sendDocument
  ├── Discord: attachment upload
  └── Slack: file.upload

11.3 TTS 处理流

Agent 回复文本


检查 TTS 是否激活
  ├── auto: "off" → 跳过
  ├── auto: "always" → 继续
  ├── auto: "inbound" → 检查用户消息是否含音频
  └── auto: "tagged" → 检查 /tts 标签


文本预处理
  ├── 长度检查(> maxTextLength?)
  │     ├── 是 + 摘要开启 → 调用 AI 摘要化
  │     └── 是 + 摘要关闭 → 截断
  ├── Markdown 清理(去除代码块等)
  └── 解析 TTS 指令覆盖


Provider 调度
  ├── edge → Edge TTS SDK
  │     └── WebSocket 连接 + SSML 合成
  ├── openai → OpenAI TTS API
  │     └── POST /v1/audio/speech
  └── elevenlabs → ElevenLabs API
        └── POST /v1/text-to-speech/{voiceId}


音频文件保存到 mediaDir


作为媒体附件发送给用户

十二、设计模式总结

![五种设计模式总览](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/10-infographic-design-patterns-1775150637572.png)

12.1 策略模式(Strategy Pattern)

  • TTS Provider 选择:"edge" | "openai" | "elevenlabs"
  • TTS 自动模式:"off" | "always" | "inbound" | "tagged"
  • SSRF 防护策略:"strict" | "relaxed" | "off"
  • 图片模型选择:按 Provider 智能配对

12.2 管道模式(Pipeline Pattern)

媒体处理是典型的管道:接收 → 检测 → 存储 → 服务。每个阶段独立,中间产物(SavedMedia)在阶段间传递。

12.3 Fallback 链模式

图片模型和 TTS Provider 都支持 Fallback:

  • 同 Provider 视觉模型 → Anthropic → OpenAI → Google
  • Primary TTS → 降级到免费 Edge TTS

12.4 分层防御模式(Defense in Depth)

![六层纵深安全防御](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(七)媒体处理系统/11-infographic-defense-in-depth-1775150638497.png)

媒体安全采用多层防御:

  • Layer 1: 大小限制(5MB 硬性上限)
  • Layer 2: SSRF 防护(DNS pinning + IP 过滤 + 协议限制)
  • Layer 3: 路径安全(符号链接检查 + 路径遍历检查 + TOCTOU 防护)
  • Layer 4: 文件权限(0o600 仅 owner 可读写)
  • Layer 5: HTTP 安全头(nosniff + no-cache)
  • Layer 6: 服务器绑定(仅 localhost)

12.5 RAII 模式(资源获取即初始化)

typescript
// fetchRemoteMedia 中的 release 机制
try {
  const result = await fetchWithSsrFGuard(...);
  release = result.release;
  // ... 使用 result
} finally {
  if (release) await release();  // 确保资源释放
}

十三、调试建议

13.1 关键断点位置

文件位置断点目的
src/media/store.tssaveMediaSource()媒体保存入口
src/media/store.tsdownloadToFile()URL 下载过程
src/media/store.tssaveMediaBuffer()Buffer 保存(入站媒体)
src/media/store.tscleanOldMedia()TTL 清理触发
src/media/fetch.tsfetchRemoteMedia()SSRF 防护链
src/media/mime.tsdetectMime()MIME 三层检测
src/media/server.tsattachMediaRoutes()HTTP 服务器挂载
src/tts/tts.tsresolveTtsConfig()TTS 配置解析
src/tts/tts-core.tsProvider 分发点TTS 合成调度
src/agents/tools/image-tool.tsresolveImageModelConfigForTool()图片模型选择

13.2 测试命令

bash
# 媒体存储测试
pnpm test -- --reporter verbose src/media/store.test.ts

# 媒体拉取测试
pnpm test -- --reporter verbose src/media/fetch.test.ts

# MIME 检测测试
pnpm test -- --reporter verbose src/media/mime.test.ts

# TTS 测试
pnpm test -- --reporter verbose src/tts/

# 图片工具测试
pnpm test -- --reporter verbose src/agents/tools/image-tool.test.ts

# 媒体服务器测试
pnpm test -- --reporter verbose src/media/server.test.ts

十四、设计洞察

14.1 为什么用 Magic Bytes 而不只靠 Content-Type

HTTP Content-Type 头是服务器自报的,可以伪造。攻击者可以设置 Content-Type: image/jpeg 但实际内容是恶意脚本。Magic Bytes 检测直接读取文件二进制签名,不可伪造。

三层检测链的设计哲学:最可信的检测方法优先,不可信的作为 Fallback

14.2 为什么 TTS 的 Provider 切换默认关闭

allowProvider: false 是一个有趣的安全决策。用户通过消息指令切换 Provider 会产生费用影响:

  • Edge TTS → 免费
  • OpenAI TTS → 按字符计费
  • ElevenLabs → 按字符计费且更贵

如果默认允许切换,用户可能在不知情的情况下从免费 Provider 切到付费 Provider。所以这个开关默认关闭,管理员需要显式开启。

14.3 入站媒体的文件名嵌入设计

{sanitized}---{uuid}.ext 的命名格式用 --- 三横杠作为分隔符,原因是:

  • 单横杠 - 在普通文件名中很常见,会造成歧义
  • 双横杠 -- 也较常见
  • 三横杠 --- 极少出现在正常文件名中,且在 UUID 中不可能出现(UUID 用单横杠)

这使得逆向解析(从 ID 提取原始文件名)变得简单可靠。

14.4 媒体服务器只绑定 localhost 的原因

媒体文件可能包含用户的私密图片、语音消息等敏感内容。将服务器绑定到 localhost 确保只有同一台机器上的进程可以访问。外部设备通过 Gateway 的认证代理访问,不能直接获取媒体文件。

14.5 cleanOldMedia 的"顺便清理"策略

TTL 清理不是通过定时器触发的,而是在每次 saveMediaSource() 时"顺便"执行。这种设计:

  • 优点:无需额外的定时器/cron 基础设施
  • 优点:写入频繁时清理也频繁(写入量大 = 磁盘压力大 = 需要更频繁清理)
  • 缺点:长时间无新媒体时不会清理(但 TTL 是 2 小时,影响有限)

读文档、看源码、写代码,理解 AI Agent 本质 🤖