# v2.8.8 (2026-04-29)

**WS BOT 图片收/发体检修复 — replyMedia 通道纠正 + 多图入向 + fetch UA 防 CDN 拦截**

## 背景

用户反馈 Bot WebSocket 模式下图片"不能正确解析、不能正常发送"。完整审计 bot-ws 的 inbound（媒体解析）+ outbound（媒体上传/回复）两条链路后，确认是**多个独立缺陷叠加**，而不是单点失败。本次一次性修掉其中影响成功率最高的几个，让 WS 通道在不回退到 Agent API 的情况下也能稳定发图。

## 改动

### 1. ⭐ Reply 上下文用错 SDK 通道（P0）

[`src/transport/bot-ws/reply.ts:13,348-356`](src/transport/bot-ws/reply.ts:13)

`reply.ts.deliver()` 在响应用户消息时发图，调的是 `uploadAndSendBotWsMedia` → SDK `sendMediaMessage(chatId, ...)` —— 这是 **aibot_send_msg（主动推送）通道**，不绑定 reqId；正确的做法是 `uploadAndReplyBotWsMedia` → SDK `replyMedia(frame, ...)`，走 **aibot_respond_msg（被动回复）通道**，让图片消息和用户的 reqId 关联。

后者其实早就在 [`media.ts:278`](src/transport/bot-ws/media.ts:278) 写好了，但**全代码库零调用方**——纯死代码。本版启用，并把 `reply.test.ts` 的 mock 同步到新 API 上。

> SDK 文档原话：`replyMedia` 是"被动回复媒体消息"，"通过 aibot_respond_msg 被动回复通道发送"，"透传 headers.req_id"；`sendMediaMessage` 是"主动发送媒体消息"，"通过 aibot_send_msg 主动推送通道发送"。两条通道在企微侧的呈现方式、跟用户对话上下文的绑定方式都不同。

`sdk-adapter.ts` 里 `sendMedia` handler（用于 outbound active push 路径）保留 `uploadAndSendBotWsMedia` 不变 —— 那个场景是"Agent 主动给用户发图"，本来就该走主动通道。

### 2. ⭐ 出向 fetch 全面切换到 `fetchRemoteMedia` + desktop UA（P1）

[`src/wecom_msg_adapter/image_fetcher.ts`](src/wecom_msg_adapter/image_fetcher.ts) 重写、[`src/outbound.ts:696-704`](src/outbound.ts:696)

之前 `loadImageAsPayload` 和 `outbound.ts` 的 `sendMedia` 都是裸 `fetch(url, { signal: AbortSignal.timeout(30000) })`：

- 没有 User-Agent → 部分 Tencent COS / 阿里 OSS bucket 直接 403
- 没有 SSRF 防护 / redirect 跟随策略 / read-idle 超时
- 没有 maxBytes 控制，理论上能把 RAM 撑爆

统一改成走 openclaw plugin-sdk 的 `fetchRemoteMedia()`：

- 显式带 `user-agent: Mozilla/5.0 ... Chrome/124.0 ...`
- 默认 20MB 上限（可由 `channels.wecom.mediaMaxMb` override）
- 复用 SDK 的 SSRF / redirect / readIdleTimeout 机制

### 3. 入向多图处理 + quote.mixed 全部抽取（P1）

[`src/transport/bot-ws/inbound.ts`](src/transport/bot-ws/inbound.ts)、[`src/shared/media-service.ts:53-95`](src/shared/media-service.ts:53)、[`src/runtime/session-manager.ts:82-114`](src/runtime/session-manager.ts:82)

- `quote.mixed` 之前只取首张图（注释说"为了和 webhook 一致"），现在 webhook 路径已经支持完整 mixed 提取，bot-ws 这边对齐：把所有 image/file/video 都收上来。
- `WecomMediaService` 加 `normalizeAllAttachments()`，把所有 attachment 解密下载下来；保留 `normalizeFirstAttachment()` 兼容老调用方。
- `prepareInboundSession` 在多图（≥2）场景下：首张照旧挂到 `ctx.MediaPath`（保持上层 agent 行为不变），其余落盘到 inbound dir 并打 `console.info` 日志方便排查；单图走老路径不变。

### 4. 入向解析可观测性（P1）

[`src/transport/bot-ws/inbound.ts:118-129`](src/transport/bot-ws/inbound.ts:118)

`msgtype` 是 image/file/video/mixed 但解析后 `attachments` 为空时，输出 warn 日志（含 msgid + body 顶层 keys），方便定位"企微 SDK 字段名漂移"或"消息体结构异常"。

## 测试

- `tsc --noEmit`：通过
- `vitest run`：176 测试中 168 通过 / 8 失败 — 与 v2.8.7 baseline **完全一致，0 regression**。失败均为 v2.8.5 之前已存在的（outbound × 4、channel.config × 2、monitor.active × 2、test-utils path 缺失 × 2），不在本次 scope 内。
- `reply.test.ts` 13/13 通过（mock 已切到 `uploadAndReplyBotWsMedia`，断言加上 `frame.headers.req_id`）。
- `inbound.test.ts` 中 `quote.mixed` 测试更新为期望提取全部图片（2 张），命名也对应改。

## 兼容性

完全向下兼容。

- 单图入向行为不变（仍只 `ctx.MediaPath`），下游 agent 不需要改
- `normalizeFirstAttachment` 公开方法保留
- `image_fetcher.loadImageAsPayload(url)` 一参形式仍支持（`maxBytes` 是新增可选参数）
- 出向 reply 用了不同 SDK 方法但用户感知一致（图片照旧到达），并发场景下因为绑定 reqId 反而更稳

## 不变

- Bot WS 主动推送（`sdk-adapter.ts` 的 `sendMedia` handler）仍走 `sendMediaMessage` —— 那是 outbound active push 的正确通道
- `MAX_PARTIAL_REPLIES = 8` / `MAX_KEEPALIVE_MS = 120s` / ack timeout watchdog 阈值不变
- v2.8.2 的 `buildImageFailurePlaceholder` / `sanitizeResidualImageMarkdown` 兜底机制不变
