Skip to content

让 Claw 看懂 WPS 协作里的图片

上一篇打通了 URL 和本地图片的分析链路——proxy 下载图片到本地,Cursor Agent 用 Read 工具看。但 WPS 协作的图片既不是 URL 也不是本地文件,而是一个 storage_key 编码标识,需要额外的鉴权步骤。

这篇在上一篇的基础上,增加两个东西:

  1. proxy 层自动解析 storage_key(被动:协作消息推送进来时自动处理)
  2. wpsv7-skill 的 download-image 命令(主动:Agent 拉取历史消息后手动下载)

前置条件:已完成 OpenClaw + WPS 协作接入

WPS 图片的存储机制

WPS IM 的图片消息不直接给 URL,而是给一个 storage_key

json
{
  "type": "image",
  "content": {
    "image": {
      "storage_key": "066F31F2cGljLzNhZDA0NmI1YzJm...",
      "type": "image/png",
      "width": 569,
      "height": 472
    }
  }
}

storage_key 是 base64 编码的内部存储标识,解码后包含 KS3 对象存储的 bucket 和 key。066F31F2 是 chat 图片的固定前缀。

下载需要两步鉴权:

storage_key ──MIME API──→ 签名 CDN URL ──GET──→ 图片文件

MIME API:

GET https://365.kdocs.cn/woa/api/v4/mime/download?biz_source=chat&key=<storage_key>
Cookie: wps_sid=<sid>; csrf=<sid>
Referer: https://365.kdocs.cn/woa/im/messages

返回带签名的临时 CDN 链接,直接 GET 即可下载。CookieReferer 缺一不可,否则 403。

被动路径:proxy 层自动解析

Agentspace 插件在收到图片消息时,把 storage_key 以固定格式拼入消息文本:

[Attached image: wps_storage_key:066F31F2cGljLzNhZDA0...]

streaming-proxy 的 resolveWpsKeysInText 用正则匹配这个格式,自动调 MIME API 换取签名 URL,下载到本地,再替换为本地路径:

[Attached image: /tmp/openclaw-images/img-a1b2c3d4e5f6.png]

之后就和上一篇一样了——Cursor Agent 用 Read 工具读取本地图片。

关键实现

proxy 里的 WPS 图片解析链:

javascript
// 正则匹配 storage_key 标记
const WPS_STORAGE_KEY_RE = /\[Attached image: wps_storage_key:([A-Za-z0-9+/=]+)\]/g;

// MIME API 换取签名 URL
async function resolveWpsStorageKey(storageKey) {
  const sid = loadWpsSid();
  const key = storageKey.startsWith("066F31F2") ? storageKey : `066F31F2${storageKey}`;
  const apiUrl = `${WPS_MIME_API}?biz_source=chat&key=${encodeURIComponent(key)}`;
  const json = await httpGetJson(apiUrl, {
    cookie: `wps_sid=${sid}; csrf=${sid}`,
    Referer: "https://365.kdocs.cn/woa/im/messages",
  });
  return json?.url || null;
}

// 下载并保存
async function saveWpsImage(storageKey) {
  const signedUrl = await resolveWpsStorageKey(storageKey);
  if (!signedUrl) return null;
  return saveImageFromUrl(signedUrl);  // 复用上一篇的通用下载函数
}

loadWpsSid()~/.openclaw/openclaw.json 动态读取 wps_sid,不硬编码。

限制

被动路径依赖 Agentspace 插件的消息推送。但 Agentspace WebSocket 目前只推送文本消息,图片事件不会触发推送。所以被动路径的实际触发场景有限——主要是插件层面把 storage_key 拼入文本后转发时才会走到。

主动路径:download-image 命令

更可靠的方式是让 Agent 主动拉取历史消息,找到图片后用命令下载。

下载函数

wpsv7client/im.py 中添加:

python
def download_image(
    storage_key: str,
    output_path: Optional[str] = None,
    client: Optional[WpsV7Client] = None,
) -> dict:
    """通过 MIME API 将 storage_key 解析为签名 CDN URL 后下载。"""
    import hashlib
    import json as _json

    c = client or WpsV7Client()
    if not c.sid:
        raise ValueError("缺少用户凭证: 请设置环境变量 wps_sid 或 WPS_SID")

    mime_base = "https://365.kdocs.cn"
    key = storage_key if storage_key.startswith("066F31F2") else f"066F31F2{storage_key}"
    mime_url = f"{mime_base}/woa/api/v4/mime/download?biz_source=chat&key={key}"

    headers = {
        "cookie": f"wps_sid={c.sid}; csrf={c.sid}",
        "Referer": "https://365.kdocs.cn/woa/im/messages",
    }

    req = urllib.request.Request(mime_url, headers=headers)
    with urllib.request.urlopen(req, timeout=15) as resp:
        mime_resp = _json.loads(resp.read())

    signed_url = mime_resp.get("url")
    if not signed_url:
        return {"code": -1, "msg": "MIME API 未返回 url", "raw": mime_resp}

    if not output_path:
        os.makedirs("/tmp/openclaw-images", exist_ok=True)
        short_hash = hashlib.md5(storage_key.encode()).hexdigest()[:12]
        output_path = f"/tmp/openclaw-images/wps-{short_hash}.jpg"

    dl_req = urllib.request.Request(signed_url)
    with urllib.request.urlopen(dl_req, timeout=30) as resp:
        data = resp.read()

    with open(output_path, "wb") as f:
        f.write(data)

    return {"url": signed_url, "output": output_path, "size": len(data)}

命令行入口

skills/im/run.py 注册 download-image 子命令:

python
p = sub.add_parser("download-image", help="下载 IM 图片(通过 storage_key)")
p.add_argument("storage_key", nargs="?", default=None,
               help="图片的 storage_key(base64 编码)")
p.add_argument("--output", "-o", default=None,
               help="保存路径,默认 /tmp/openclaw-images/wps-<hash>.jpg")
p.set_defaults(func=cmd_download_image)

SKILL.md:告诉 Agent 怎么用

skills/im/SKILL.md 加上醒目的说明:

markdown
## ⚠️ 图片下载(重要)

当历史消息中出现 `type=image` 的消息,其 `content.image.storage_key` 就是图片标识。
**必须用此命令下载,不要手动拼 URL:**

python skills/im/run.py download-image "<storage_key>"

**典型流程:** history 拉消息 → 找到 type=image → 取 storage_key → download-image 下载 → Read 查看

"不要手动拼 URL"是关键——实测中 Agent 在没有明确指引时花了 44 次 tool call 试遍各种下载方式后宣布"图片无解"。

wps_sid 管理

不管是 proxy 还是 skill,都需要 wps_sid 鉴权。唯一真实来源是 ~/.openclaw/openclaw.json

~/.openclaw/openclaw.json
  └─ channels.agentspace.accounts.default.wps_sid

       ├──→ streaming-proxy.mjs  (loadWpsSid() 动态读)
       ├──→ gateway-wrapper.sh   (启动时注入环境变量)
       └──→ wpsv7-skill/base.py  (环境变量优先,fallback 读 json)

Gateway 启动脚本

~/.local/bin/openclaw-gateway-wrapper.sh 每次启动时从配置读取 wps_sid,注入环境变量给 Agent 进程继承:

bash
#!/bin/bash
CONFIG="$HOME/.openclaw/openclaw.json"
if [ -f "$CONFIG" ]; then
    SID=$(python3 -c "
import json
print(json.load(open('$CONFIG'))
    .get('channels',{})
    .get('agentspace',{})
    .get('accounts',{})
    .get('default',{})
    .get('wps_sid',''))" 2>/dev/null)
    if [ -n "$SID" ]; then
        export WPS_SID="$SID"
        export wps_sid="$SID"
    fi
fi
exec openclaw gateway

base.py Fallback

即使环境变量不可用,skill 也能直接从配置文件读取:

python
class WpsV7Client:
    def __init__(self, base_url=None, sid=None):
        self.sid = (sid
                    or os.environ.get("wps_sid")
                    or os.environ.get("WPS_SID")
                    or self._load_sid_from_config())

    @staticmethod
    def _load_sid_from_config():
        try:
            cfg_path = os.path.join(os.path.expanduser("~"),
                                    ".openclaw", "openclaw.json")
            with open(cfg_path) as f:
                cfg = json.load(f)
            return (cfg.get("channels", {})
                       .get("agentspace", {})
                       .get("accounts", {})
                       .get("default", {})
                       .get("wps_sid"))
        except Exception:
            return None

wps_sid 过期后重新登录,重启 Gateway 即全部生效:

bash
openclaw config   # 重新 OAuth
systemctl --user restart openclaw-gateway

验证

手动测试

bash
cd ~/Documents/OpenClaw/skills/wpsv7-skill

# 找图片消息
python skills/im/run.py search-messages \
  --chat-ids "<chat_id>" --msg-types "image" --page-size 1

# 下载
python skills/im/run.py download-image "066F31F2cGljLzNhZDA0NmI1YzJm..."

协作端测试

在 WPS 协作里对 Claw 说:

帮我看看这个群里最新的图片是什么内容

Agent 的正确行为链:history → 找 type=image → download-image → Read → 回复。

日志确认:

bash
grep -E "history|download-image" ~/.openclaw/cursor-proxy.log | tail -10

如果 Agent 在尝试各种 curl 而不是 download-image,说明 SKILL.md 没生效——清 session 后重试:

bash
rm -f ~/.openclaw/cursor-sessions.json
systemctl --user restart openclaw-gateway

踩过的坑

现象根因
Agent 花 44 次 tool call 后说"图片无解"SKILL.md 缺图片下载说明
更新 SKILL.md 后 Agent 还说"看不了"没删 ~/.openclaw/cursor-sessions.json,复用了老 session
Agent 调 skill 超时 300swps_sid 不在环境变量里,base.py 也没 fallback
MIME API 返回 403缺 Cookie 或 Referer
storage_key 前缀重复导致下载失败V7 API 返回的 key 已含 066F31F2,代码要判断去重
用户发图 Agent 无反应Agentspace 不推送图片事件,需要文字触发 Agent 主动拉取

最后更新于:

Hosted by GitHub Pages