主题
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 系统全景
媒体处理系统/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
媒体处理系统/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 文件命名策略
媒体处理系统/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 类型检测——三层检测链
媒体处理系统/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 映射表
│
├── 匹配 → 返回
│
└── 全部失败 → 返回 null3.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 防护
媒体处理系统/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 Pinning:
resolvePinnedHostnameImpl()将域名解析为 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 服务器
媒体处理系统/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-Options | nosniff | 防止浏览器猜测 MIME 类型 |
Cache-Control | no-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 架构
媒体处理系统/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 视觉模型
媒体处理系统/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:
- Auth Profile Store 中的 Key
- 环境变量中的 Key
- 自定义 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[]确定媒体文件的允许读取范围(仅工作区目录),防止路径遍历攻击。
十一、完整数据流
媒体处理系统/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.upload11.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
│
▼
作为媒体附件发送给用户十二、设计模式总结
媒体处理系统/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)
媒体处理系统/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.ts | saveMediaSource() | 媒体保存入口 |
src/media/store.ts | downloadToFile() | URL 下载过程 |
src/media/store.ts | saveMediaBuffer() | Buffer 保存(入站媒体) |
src/media/store.ts | cleanOldMedia() | TTL 清理触发 |
src/media/fetch.ts | fetchRemoteMedia() | SSRF 防护链 |
src/media/mime.ts | detectMime() | MIME 三层检测 |
src/media/server.ts | attachMediaRoutes() | HTTP 服务器挂载 |
src/tts/tts.ts | resolveTtsConfig() | TTS 配置解析 |
src/tts/tts-core.ts | Provider 分发点 | TTS 合成调度 |
src/agents/tools/image-tool.ts | resolveImageModelConfigForTool() | 图片模型选择 |
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 小时,影响有限)