Skill: haoruilee/aiping-media (version 1.2.0)

--- name: aiping-media description: AIPing 媒体生成技能，支持图片生成和视频生成。生成后自动下载到本地，优先通过飞书发送原生图片/视频消息，CDN 链接作为备选。当用户要求生成图片、生成视频、图片生成、视频制作、AI绘图、文生图、文生视频时自动触发。 primaryEnv: AIPING_API_KEY requires: env: - AIPING_API_KEY - FEISHU_APP_ID - FEISHU_APP_SECRET --- # AIPing Media Skill 通过 AIPing API 生成图片和视频，完整流程：**生成 → 本地下载 → 飞书原生发送**，CDN 链接作为备选。 ## 完整工作流 ``` 生成图片/视频 → 自动下载到本地 (/tmp/) → 上传飞书 → 发送原生消息 ↓ 失败 返回 CDN 链接 ``` --- ## 快速上手（新用户从零到跑通） ### 第一步：安装技能 ```bash clawhub install aiping-media ``` ### 第二步：配置环境变量 在 `~/.openclaw/openclaw.json` 的 `env` 块中添加三个变量。推荐用 CLI 写入，避免手动编辑 JSON： ```bash openclaw config set env.AIPING_API_KEY "你的AIPing Key" openclaw config set env.FEISHU_APP_ID "cli_xxxxxxxxxxxxxxxx" openclaw config set env.FEISHU_APP_SECRET "你的飞书AppSecret" ``` > **获取飞书 AppID / AppSecret**：飞书开放平台 → 你的应用 → 凭证与基础信息 验证写入成功： ```bash openclaw config get env.AIPING_API_KEY openclaw config get env.FEISHU_APP_ID ``` ### 第三步：重启 Gateway（让 env 变量生效） ```bash openclaw gateway restart ``` ### 第四步：获取自己的飞书 open_id 以下命令自动用配置中的 AppID/Secret 获取 token，再查询你自己的 open_id（把 `你的手机号或邮箱` 替换成实际值）： ```bash # 获取 token [REDACTED_SECRET] -c " import requests, json with open('$(echo ~/.openclaw/openclaw.json)') as f: cfg = json.load(f) env = cfg['env'] r = requests.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', json={'app_id': env['FEISHU_APP_ID'], 'app_secret': env['FEISHU_APP_SECRET']}) print(r.json()['tenant_access_token']) ") # 按手机号查 open_id（换成你的手机号，含国家码如 +8613800138000） curl -s "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"mobiles": ["你的手机号"]}' | python3 -m json.tool ``` 记下 `open_id`（格式如 [REDACTED_SECRET]），后续测试用。 ### 第五步：一键测试（图片生成 + 飞书发送） ```bash SKILL_DIR=~/.openclaw/workspace/skills/aiping-media # 生成图片（默认模型 Doubao-Seedream-5.0-lite，~5秒） IMG_JSON=$(python3 $SKILL_DIR/scripts/generate_image.py "赛博朋克城市夜景，霓虹灯，超写实") LOCAL_PATH=$(echo $IMG_JSON | python3 -c "import sys,json; print(json.load(sys.stdin)['local_path'])") echo "图片已下载到: $LOCAL_PATH" # 发送到飞书（替换 open_id） python3 $SKILL_DIR/scripts/send_feishu.py image ou_你的open_id $LOCAL_PATH ``` 输出 `"success": true` 即代表完整链路跑通。 ### 第六步：测试视频生成 ```bash SKILL_DIR=~/.openclaw/workspace/skills/aiping-media # 提交任务（视频生成通常需要 1~3 分钟） TASK_JSON=$(python3 $SKILL_DIR/scripts/generate_video.py submit Kling-V3-Omni "一只橘猫在草地上打滚，阳光明媚" 5 pro 16:9) TASK_ID=$(echo $TASK_JSON | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") echo "Task ID: $TASK_ID" # 等待完成并自动下载（轮询，最长等 5 分钟） VIDEO_JSON=$(python3 $SKILL_DIR/scripts/generate_video.py wait $TASK_ID) VIDEO_PATH=$(echo $VIDEO_JSON | python3 -c "import sys,json; print(json.load(sys.stdin).get('local_path',''))") echo "视频已下载到: $VIDEO_PATH" # 发送到飞书（群聊用 chat_id） python3 $SKILL_DIR/scripts/send_feishu.py video oc_群聊ID $VIDEO_PATH chat_id ``` --- ## 在 Agent 代码中调用 ### 图片生成 + 飞书发送 ```python from scripts.generate_image import generate_image from scripts.send_feishu import send_image # 1. 生成（自动下载到本地） img = generate_image( model="Kling-V1", prompt="未来城市夜景，赛博朋克风格", negative_prompt="低质量，模糊" ) local_path = img.get("local_path") # 自动下载路径 # 2. 发送飞书 if local_path: # 发送给用户（默认 open_id） result = send_image(token=None, receive_id="ou_xxx", source=local_path) # 或发送到群聊（指定 chat_id） result = send_image(token=None, receive_id="oc_xxx", source=local_path, receive_id_type="chat_id") if result.get("success"): print(f"飞书 image_key: {result.get('image_key')}") else: print(f"失败: {result.get('error')}") print(f"CDN链接: {result.get('cdn_url')}") ``` ### 视频生成 + 飞书发送 ```python from scripts.generate_video import generate_video, wait_for_video from scripts.send_feishu import send_video # 1. 提交任务 video = generate_video( model="Kling-V3-Omni", prompt="一只猫咪在草地上玩耍", seconds=5, mode="pro", aspect_ratio="1:1" ) task_id = video["id"] # 2. 等待完成（自动下载） result = wait_for_video(task_id) local_path = result.get("local_path") # 3. 发送到群聊 if local_path: result = send_video(token=None, receive_id="oc_xxx", source=local_path, receive_id_type="chat_id") ``` --- ## 推荐模型 ### 图片 | 模型 | 说明 | |------|------| | `Doubao-Seedream-5.0-lite` | 通用文生图，支持垫图（默认） | | `Kling-V1` | 高质量，速度快 | | `HunyuanImage-3.0` | 腾讯混元 | ### 视频 | 模型 | mode | aspect_ratio | 说明 | |------|------|---------------|------| | `Kling-V3-Omni` | pro/std | 16:9, 9:16, 1:1 | 最新全能版，推荐 | | `Kling-V2.6` | std | 16:9, 9:16, 1:1 | 稳定版 | --- ## 脚本命令行用法 ### generate_image.py ```bash python3 scripts/generate_image.py <prompt> [model] [negative_prompt] [image_url] [aspect_ratio] [nodownload] ``` ### generate_video.py ```bash # 提交任务 python3 scripts/generate_video.py submit <model> <prompt> [seconds] [mode] [aspect_ratio] # 等待完成（自动下载） python3 scripts/generate_video.py wait <task_id> # 查询状态 python3 scripts/generate_video.py query <task_id> ``` ### send_feishu.py ```bash # 图片（本地文件或 CDN URL） python3 scripts/send_feishu.py image <receive_id> <file_or_url> [receive_id_type] # 视频（本地文件或 CDN URL） python3 scripts/send_feishu.py video <receive_id> <file_or_url> [receive_id_type] ``` **关键参数 `receive_id_type`**（默认 `open_id`，易踩坑！）： | receive_id_type | 适用场景 | receive_id 示例 | |-----------------|---------|----------------| | `open_id` | 发送给**单个用户** | [REDACTED_SECRET] | | `chat_id` | 发送到**群聊** | [REDACTED_SECRET] | > ⚠️ **群聊必须用 `chat_id`**，不能拿用户的 open_id 发到群里，否则返回 `400 Bad Request` **示例：** ```bash # 发送给用户（默认 open_id） python3 scripts/send_feishu.py image ou_xxxxxxxx /tmp/a.jpg # 发送到群聊（指定 chat_id） python3 scripts/send_feishu.py image oc_xxxxxxxx /tmp/a.jpg chat_id ``` --- ## 飞书上传接口详解（踩坑经验汇总） ### 图片上传 vs 视频上传（对比） | | 图片 | 视频 | |--|------|------| | **上传接口** | `POST /im/v1/images` | `POST /im/v1/files` | | **参数传递方式** | `multipart/form-data`（`-F`） | `multipart/form-data`（`-F`） | | **必需参数** | `image_type=message` | `file_type=mp4` + `file_name=xxx.mp4` | | **返回值 key** | `image_key` | `file_key` | | **发送时 msg_type** | `image` | `media` | | **content 格式** | `{"image_key": "xxx"}` | `{"file_key": "xxx", "type": "video"}` | ### 完整 curl 示例（图片） ```bash # 1. 获取 token [REDACTED_SECRET] -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \ -H "Content-Type: application/json" \ -d '{"app_id": "cli_xxx", "app_secret": "xxx"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['tenant_access_token'])") # 2. 上传图片 IMAGE_KEY=$(curl -s -X POST "https://open.feishu.cn/open-apis/im/v1/images" \ -H "Authorization: Bearer $TOKEN" \ -F "image_type=message" \ -F "image=@/tmp/image.jpg" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['image_key'])") # 3. 发送图片 curl -s -X POST "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"receive_id\": \"oc_xxx\", \"msg_type\": \"image\", \"content\": \"{\\"image_key\\": \\"$IMAGE_KEY\\"}\"}" ``` ### 完整 curl 示例（视频） ```bash # 1. 获取 token [REDACTED_SECRET] -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \ -H "Content-Type: application/json" \ -d '{"app_id": "cli_xxx", "app_secret": "xxx"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['tenant_access_token'])") # 2. 上传视频（注意：file_type 必须是 "mp4"，不是 "video/mp4"！） FILE_KEY=$(curl -s -X POST "https://open.feishu.cn/open-apis/im/v1/files" \ -H "Authorization: Bearer $TOKEN" \ -F "file_type=mp4" \ -F "file_name=video.mp4" \ -F "file=@/tmp/video.mp4" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['file_key'])") # 3. 发送视频（msg_type 是 "media"，content 里 type 是 "video"） curl -s -X POST "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"receive_id\": \"oc_xxx\", \"msg_type\": \"media\", \"content\": \"{\\"file_key\\": \\"$FILE_KEY\\", \\"type\\": \\"video\\"}\"}" ``` ### 飞书上传最容易踩的坑 | 错误写法 | 正确写法 | 说明 | |---------|---------|------| | 上传图片设 `Content-Type: application/json` | 不要设 Content-Type，让 curl 自动生成 | multipart/form-data 不能手动设 Content-Type | | `file_type=video/mp4` | `file_type=mp4` | 文件类型是枚举值，不是 MIME type！ | | 上传视频缺少 `file_name` | 必须加 `file_name=xxx.mp4` | 否则报 234001 参数错误 | | 视频发送用 `msg_type=video` | 用 `msg_type=media` | 视频/音频/文件都是 media 类型 | | `content: {"video_key": "xxx"}` | `content: {"file_key": "xxx", "type": "video"}` | 字段名是 `file_key`，不是 `video_key` | --- ## 注意事项 1. **env 变量必须配置**：技能脚本从 `AIPING_API_KEY` / `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 读取凭证，未配置时调用函数会抛 `EnvironmentError` 2. **重启 gateway 后才生效**：修改 `openclaw.json` 的 env 块后必须 `openclaw gateway restart` 3. **本地文件限制 /tmp/**：`send_feishu.py` 只允许读取 `/tmp/` 下的本地文件，生成脚本默认也写入 `/tmp/` 4. **自动 MIME 检测**：根据文件扩展名自动识别 PNG/JPEG/GIF/MP4 5. **视频生成需要耐心**：视频任务提交后 status 为 `queued`，需要等待 1~3 分钟才能完成，用 `wait` 命令轮询查询 6. **上传接口不能混用**：图片必须用 `/im/v1/images`，视频必须用 `/im/v1/files`，两者接口不同 ## 常见错误排查 | 错误信息 | 原因 | 解决方案 | |---------|------|---------| | `400 Bad Request /im/v1/messages` | `receive_id_type` 与 `receive_id` 不匹配（如群聊用了 open_id） | 群聊传 `chat_id` + `receive_id_type="chat_id"`，个人用 `open_id` | | `飞书错误码 234001` | 参数错误，上传接口参数格式不对 | 对照上方「最容易踩的坑」检查：file_type 枚举值、file_name 是否传入、Content-Type 是否误设 | | `飞书错误码 230001` | 缺少 `im:resource` 权限 | 飞书开放平台 → 权限管理 → 开通 `im:resource` → 重新发布 | | `飞书错误码 99991663` | `tenant_access_token` 过期 | token 会自动续期，若仍失败重启 gateway | | `本地文件路径必须在 /tmp/` | 读取了 `/tmp/` 以外的文件 | 只传 `/tmp/` 下的文件路径 | | `image_key / file_key 无效` | 图片上传和发送用了不同的 token 或应用 | 确保同一应用内完成上传和发送 | | 视频发送成功但群里看不到 | `file_key` 与 `file_name` 来自不同文件 | 每次发送都要重新上传，获取新的 file_key | | AIPing 返回 `503 暂无可用服务商` | 视频生成模型不支持 chat/completions 接口 | 视频生成必须用 `/api/v1/videos` 端点，不是 `/chat/completions` |

path: _meta.json
content: ownerId: kn74dzv3k1pcfh8z06tj9p8gws81cme9
slug: aiping-media
version: 1.0.5
publishedAt: 1775873957580

path: references/models.md
content: # AIPing 支持的模型列表

## 图片生成模型

| 模型 | 说明 | 垫图支持 | 宽高比 |
|------|------|----------|--------|
| `Doubao-Seedream-4.0` | 豆包·即梦 4.0 | ✅ | 多种 |
| `Doubao-Seedream-4.5` | 豆包·即梦 4.5 | ✅ | 多种 |
| `Doubao-Seedream-5.0-lite` | 豆包·即梦 5.0 轻量版 | ✅ | 多种 |
| `Kling-V1` | 可灵 1.0 | ❌ | 需指定 aspect_ratio |
| `Kling-V1.5` | 可灵 1.5 | ❌ | 多种 |
| `Kling-V2` | 可灵 2.0 | ❌ | 多种 |
| `Kling-V2.1` | 可灵 2.1 | ❌ | 多种 |
| `Kling-V2-New` | 可灵 2.0 新版 | ❌ | 多种 |
| `Qwen-Image` | 通义千问图像 | ❌ | 多种 |
| `Qwen-Image-2.0` | 千问 2.0 | ❌ | 多种 |
| `Qwen-Image-2.0-Pro` | 千问 2.0 专业版 | ❌ | 多种 |
| `Qwen-Image-Plus` | 千问增强版 | ❌ | 多种 |
| `Qwen-Image-Edit` | 千问图像编辑 | ✅ | 多种 |
| `Qwen-Image-Edit-Plus` | 千问编辑增强版 | ✅ | 多种 |
| `Qwen-Image-Edit-2509-SophNet` | 千问SophNet版 | ✅ | 多种 |
| `GLM-Image` | 智谱图像 | ❌ | 多种 |
| `Gemini-3.1-Flash-Image-Preview` | Gemini 图像 | ❌ | 多种 |
| `Gemini-3-Pro-Image-Preview` | Gemini 专业图像 | ❌ | 多种 |
| `HunyuanImage-3.0` | 腾讯混元 3.0 | ❌ | 多种 |
| `Wan2.5-I2I-Preview` | 阿里 Wan 图生图 | ✅ | 多种 |
| `Wan2.5-T2I-Preview` | 阿里 Wan 文生图 | ❌ | 多种 |
| `Kolors` | Kolors 图像 | ❌ | 多种 |
| `即梦图片生成 4.0` | 即梦 4.0 | ✅ | 多种 |
| `即梦文生图 3.0` | 即梦 3.0 | ✅ | 多种 |
| `即梦文生图 3.1` | 即梦 3.1 | ✅ | 多种 |

## 视频生成模型

| 模型 | mode | aspect_ratio | 说明 |
|------|------|-------------|------|
| `Kling-V2.6` | std | 16:9, 9:16, 1:1 | 稳定版，~60s 生成 |
| `Kling-V3-Omni` | std/pro | 16:9, 9:16, 1:1 | 最新全能版，推荐 pro 模式，~120s |

### 视频参数说明

| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名，如 `Kling-V3-Omni` |
| `prompt` | string | 画面描述，建议英文效果更好 |
| `seconds` | string | 时长，默认 "5"（必须是字符串） |
| `mode` | string | `std`（标准）或 `pro`（专业，效果更好但更慢） |
| `aspect_ratio` | string | `16:9`（横屏）、`9:16`（竖屏）、`1:1`（方形） |

### 验证过的参数组合

```bash
# Kling-V2.6 标准模式
model="Kling-V2.6", mode="std", aspect_ratio="16:9", seconds="5"  # ✅ ~61s

# Kling-V3-Omni 专业模式
model="Kling-V3-Omni", mode="pro", aspect_ratio="1:1", seconds="5"  # ✅ ~122s
```

## 垫图（参考图）使用方法

部分模型支持通过 `image` 参数传入参考图 URL：

```json
{
  "model": "Doubao-Seedream-5.0-lite",
  "prompt": "一个宇航员在都市街头漫步",
  "negative_prompt": "模糊，低质量",
  "image": "http://example.com/reference.jpg"
}
```

支持的垫图模型：Doubao-Seedream 系列、Qwen-Image-Edit 系列、Wan2.5-I2I-Preview、即梦系列等。


path: scripts/generate_image.py
content: #!/usr/bin/env python3
"""
图片生成脚本 - 生成后自动下载到本地
"""
import requests
import json
import sys
import os
import time
import tempfile

[REDACTED_SECRET]"AIPING_API_KEY", "")
API_BASE = "https://aiping.cn/api/v1"

DEFAULT_MODEL = "Doubao-Seedream-5.0-lite"

def generate_image(model: str = "", prompt: str = "", negative_prompt: str = "", 
                  image_url: str = "", aspect_ratio: str = "", timeout: int = 60,
                  download: bool = True) -> dict:
    """
    生成图片，默认模型: Doubao-Seedream-5.0-lite
    download=True: 生成后自动下载到本地 /tmp/，返回 local_path
    download=False: 只返回 CDN URL
    """
    if not model:
        model = DEFAULT_MODEL
    if not prompt:
        raise ValueError("prompt 不能为空")
    [REDACTED_SECRET]"AIPING_API_KEY", API_KEY)
    if not api_key:
        raise EnvironmentError("请设置环境变量 AIPING_API_KEY")
    payload = {
        "model": model,
        "prompt": prompt
    }
    if negative_prompt:
        payload["negative_prompt"] = negative_prompt
    if image_url:
        payload["image"] = image_url
    if aspect_ratio:
        payload["aspect_ratio"] = aspect_ratio
    
    resp = requests.post(
        f"{API_BASE}/images/generations",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json"
        },
        json=payload,
        timeout=timeout
    )
    resp.raise_for_status()
    data = resp.json()
    
    result = {
        "model": model,
        "provider": data.get("provider", "")
    }
    
    if "data" in data and len(data["data"]) > 0:
        cdn_url = data["data"][0]["url"]
        result["url"] = cdn_url
        
        if download:
            # 自动下载到本地
            try:
                local_path = download_image(cdn_url)
                result["local_path"] = local_path
            except Exception as e:
                result["download_error"] = str(e)
    
    return result

def download_image(url: str) -> str:
    """下载图片到本地 /tmp/ 目录"""
    resp = requests.get(url, timeout=60, headers={
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
    })
    resp.raise_for_status()
    
    # 从 URL 推断扩展名
    if ".png" in url.lower():
        ext = "png"
    elif ".gif" in url.lower():
        ext = "gif"
    elif ".webp" in url.lower():
        ext = "webp"
    else:
        ext = "jpg"
    
    local_path = f"/tmp/aiping_img_{os.getpid()}_{int(time.time())}.{ext}"
    with open(local_path, "wb") as f:
        f.write(resp.content)
    
    return local_path

if __name__ == "__main__":
    
    if len(sys.argv) < 2:
        print(f"用法: python3 generate_image.py <prompt> [model] [negative_prompt] [image_url] [aspect_ratio] [nodownload]")
        print(f"默认模型: {DEFAULT_MODEL}")
        sys.exit(1)
    
    prompt = sys.argv[1]
    model = sys.argv[2] if len(sys.argv) > 2 else ""
    negative = sys.argv[3] if len(sys.argv) > 3 else ""
    image_url = sys.argv[4] if len(sys.argv) > 4 else ""
    aspect = sys.argv[5] if len(sys.argv) > 5 else ""
    download = "nodownload" not in sys.argv
    
    result = generate_image(model, prompt, negative, image_url, aspect, download=download)
    print(json.dumps(result, ensure_ascii=False, indent=2))


path: scripts/generate_video.py
content: #!/usr/bin/env python3
"""
视频生成脚本 - 支持提交、查询、等待、自动下载
"""
import requests
import json
import sys
import os
import time

[REDACTED_SECRET]"AIPING_API_KEY", "")
API_BASE = "https://aiping.cn/api/v1"


def _get_api_key() -> str:
    key = os.environ.get("AIPING_API_KEY", API_KEY)
    if not key:
        raise EnvironmentError("请设置环境变量 AIPING_API_KEY")
    return key

def generate_video(model: str, prompt: str, seconds: int = 5, 
                   mode: str = "std", aspect_ratio: str = "16:9",
                   timeout: int = 30) -> dict:
    """提交视频生成任务"""
    payload = {
        "model": model,
        "prompt": prompt,
        "seconds": str(seconds),  # 必须是字符串
        "mode": mode,
        "aspect_ratio": aspect_ratio
    }
    
    resp = requests.post(
        f"{API_BASE}/videos",
        headers={
            "Authorization": f"Bearer {_get_api_key()}",
            "Content-Type": "application/json"
        },
        json=payload,
        timeout=timeout
    )
    resp.raise_for_status()
    return resp.json()

def query_video(task_id: str, timeout: int = 30) -> dict:
    """查询视频生成状态"""
    resp = requests.get(
        f"{API_BASE}/videos/{task_id}",
        headers={
            "Authorization": f"Bearer {_get_api_key()}",
            "Content-Type": "application/json"
        },
        timeout=timeout
    )
    resp.raise_for_status()
    return resp.json()

def download_video(url: str) -> str:
    """下载视频到本地 /tmp/ 目录"""
    resp = requests.get(url, timeout=120, headers={
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
    }, stream=True)
    resp.raise_for_status()
    
    local_path = f"/tmp/aiping_video_{os.getpid()}_{int(time.time())}.mp4"
    with open(local_path, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)
    
    return local_path

def wait_for_video(task_id: str, poll_interval: int = 15, max_wait: int = 300,
                   auto_download: bool = True) -> dict:
    """等待视频生成完成，可选自动下载"""
    start = time.time()
    while time.time() - start < max_wait:
        result = query_video(task_id)
        status = result.get("status", "")
        elapsed = int(time.time() - start)
        print(f"[{elapsed}s] Status: {status}", file=sys.stderr)
        
        if status == "completed":
            if auto_download and result.get("video_url"):
                try:
                    local_path = download_video(result["video_url"])
                    result["local_path"] = local_path
                    print(f"[{elapsed}s] Downloaded to: {local_path}", file=sys.stderr)
                except Exception as e:
                    result["download_error"] = str(e)
                    print(f"[{elapsed}s] Download failed: {e}", file=sys.stderr)
            return result
        
        if status == "failed":
            raise Exception(f"Video generation failed: {result}")
        
        time.sleep(poll_interval)
    
    raise Exception(f"Timeout waiting for video after {max_wait}s")

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("用法:")
        print("  python3 generate_video.py submit <model> <prompt> [seconds] [mode] [aspect_ratio]")
        print("  python3 generate_video.py query <task_id>")
        print("  python3 generate_video.py wait <task_id> [auto_download]")
        print("")
        print("示例:")
        print("  python3 generate_video.py submit Kling-V3-Omni '一只猫在草地上玩耍' 5 pro 16:9")
        print("  python3 generate_video.py wait 871747046242947116")
        sys.exit(1)
    
    action = sys.argv[1]
    
    if action == "submit":
        model = sys.argv[2]
        prompt = sys.argv[3]
        seconds = int(sys.argv[4]) if len(sys.argv) > 4 else 5
        mode = sys.argv[5] if len(sys.argv) > 5 else "std"
        aspect = sys.argv[6] if len(sys.argv) > 6 else "16:9"
        
        result = generate_video(model, prompt, seconds, mode, aspect)
        print(json.dumps(result, ensure_ascii=False, indent=2))
    
    elif action == "query":
        task_id = sys.argv[2]
        result = query_video(task_id)
        print(json.dumps(result, ensure_ascii=False, indent=2))
    
    elif action == "wait":
        task_id = sys.argv[2]
        auto_download = "nodownload" not in sys.argv
        result = wait_for_video(task_id, auto_download=auto_download)
        print(json.dumps(result, ensure_ascii=False, indent=2))


path: scripts/send_feishu.py
content: #!/usr/bin/env python3
"""
飞书发送媒体文件（图片/视频）
支持本地文件或 CDN URL，自动优先上传到飞书发送，失败时返回 CDN 链接。

重要：receive_id_type 说明
- open_id  ：发送给单个用户
- chat_id  ：发送到群聊
- 使用场景：
    • 个人聊天 → open_id（用户 open_id 格式：ou_xxx）
    • 群聊     → chat_id（群 ID 格式：oc_xxx）
"""
import requests
import json
import sys
import os

FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
API_BASE = "https://open.feishu.cn/open-apis"


def _get_feishu_creds() -> tuple:
    app_id = os.environ.get("FEISHU_APP_ID", FEISHU_APP_ID)
    app_secret = os.environ.get("FEESHU_APP_SECRET", FEISHU_APP_SECRET)
    if not app_id or not app_secret:
        raise EnvironmentError("请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET")
    return app_id, app_secret


def get_token() -> str:
    """获取 tenant access token"""
    app_id, app_secret = _get_feishu_creds()
    resp = requests.post(
        f"{API_BASE}/auth/v3/tenant_access_token/internal",
        json={"app_id": app_id, "app_secret": app_secret}
    )
    resp.raise_for_status()
    return resp.json()["tenant_access_token"]


def download_file(url: str) -> bytes:
    """从 CDN URL 下载文件"""
    resp = requests.get(url, timeout=60)
    resp.raise_for_status()
    return resp.content


def upload_image(token: str, content: bytes, filename: str = "image.jpg") -> str:
    """上传图片到飞书，返回 image_key"""
    ext = os.path.splitext(filename)[1].lower()
    mime_types = {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".gif": "image/gif",
        ".webp": "image/webp",
    }
    mime = mime_types.get(ext, "image/jpeg")
    resp = requests.post(
        f"{API_BASE}/im/v1/images",
        files={"image": (filename, content, mime)},
        data={"image_type": "message"},
        headers={"Authorization": f"Bearer {token}"}
    )
    resp.raise_for_status()
    return resp.json()["data"]["image_key"]


def upload_video(token: str, content: bytes, filename: str = "video.mp4") -> str:
    """上传视频到飞书，返回 file_key（注意：视频用 /im/v1/files 接口）"""
    resp = requests.post(
        f"{API_BASE}/im/v1/files",
        files={"file": (filename, content, "video/mp4")},
        data={"file_type": "mp4"},
        headers={"Authorization": f"Bearer {token}"}
    )
    resp.raise_for_status()
    return resp.json()["data"]["file_key"]


def send_image_message(token: str, receive_id: str, image_key: str,
                       receive_id_type: str = "open_id") -> dict:
    """
    发送图片消息

    参数:
        token: 飞书 tenant access token
        receive_id: 接收方 ID（open_id 或 chat_id，取决于 receive_id_type）
        image_key: 上传后的图片 key
        receive_id_type: "open_id"（用户）或 "chat_id"（群聊），默认 open_id
    """
    resp = requests.post(
        f"{API_BASE}/im/v1/messages",
        params={"receive_id_type": receive_id_type},
        json={
            "receive_id": receive_id,
            "msg_type": "image",
            "content": json.dumps({"image_key": image_key})
        },
        headers={"Authorization": f"Bearer {token}"}
    )
    resp.raise_for_status()
    return resp.json()


def send_video_message(token: str, receive_id: str, file_key: str,
                      receive_id_type: str = "open_id") -> dict:
    """
    发送视频消息

    参数:
        token: 飞书 tenant access token
        receive_id: 接收方 ID（open_id 或 chat_id，取决于 receive_id_type）
        file_key: 上传后的视频 file key
        receive_id_type: "open_id"（用户）或 "chat_id"（群聊），默认 open_id
    """
    resp = requests.post(
        f"{API_BASE}/im/v1/messages",
        params={"receive_id_type": receive_id_type},
        json={
            "receive_id": receive_id,
            "msg_type": "media",
            "content": json.dumps({"file_key": file_key, "type": "video"})
        },
        headers={"Authorization": f"Bearer {token}"}
    )
    resp.raise_for_status()
    return resp.json()


def _read_local_file(path: str) -> bytes:
    """读取本地文件，限制只允许 /tmp/ 目录"""
    abs_path = os.path.realpath(path)
    if not abs_path.startswith("/tmp/"):
        raise ValueError(f"本地文件路径必须在 /tmp/ 目录下，拒绝访问: {path}")
    with open(abs_path, "rb") as f:
        return f.read()


def send_image(token: str, receive_id: str, source: str,
               receive_id_type: str = "open_id") -> dict:
    """
    发送图片：优先上传飞书发送，失败返回 CDN 链接

    参数:
        token: 飞书 token（传 None 则自动获取）
        receive_id: 接收方 ID（open_id 或 chat_id）
        source: /tmp/ 下的本地文件路径 或 CDN URL
        receive_id_type: "open_id" 或 "chat_id"，默认 open_id
            - open_id：发送给你用户，例如 ou_xxx
            - chat_id ：发送到群聊，例如 oc_xxx
    """
    if token is None:
        [REDACTED_SECRET])
    try:
        if source.startswith("http://") or source.startswith("https://"):
            content = download_file(source)
        else:
            content = _read_local_file(source)

        filename = os.path.basename(source) if not source.startswith("http") else "image.jpg"
        image_key = upload_image(token, content, filename)
        result = send_image_message(token, receive_id, image_key, receive_id_type)
        return {
            "success": True,
            "method": "feishu",
            "image_key": image_key,
            "receive_id_type": receive_id_type,
            "result": result
        }
    except requests.exceptions.HTTPError as e:
        # 尝试解析飞书错误码，给出更友好的提示
        try:
            err_body = e.response.json()
            code = err_body.get("code")
            msg = err_body.get("msg", "")
            err_msg = f"飞书错误码 {code}: {msg}"
            # 常见错误码说明
            if code == 230001:
                err_msg += "（请检查是否已开通 im:resource 权限）"
            elif code == 99991663:
                err_msg += "（tenant_access_token 已过期，请重新获取）"
        except Exception:
            err_msg = str(e)
        return {
            "success": False,
            "method": "cdn",
            "cdn_url": source if source.startswith("http") else None,
            "error": err_msg,
            "http_status": e.response.status_code if e.response is not None else None
        }
    except Exception as e:
        return {
            "success": False,
            "method": "cdn",
            "cdn_url": source if source.startswith("http") else None,
            "error": str(e)
        }


def send_video(token: str, receive_id: str, source: str,
               receive_id_type: str = "open_id") -> dict:
    """
    发送视频：优先上传飞书发送，失败返回 CDN 链接

    参数:
        token: 飞书 token（传 None 则自动获取）
        receive_id: 接收方 ID（open_id 或 chat_id）
        source: /tmp/ 下的本地文件路径 或 CDN URL
        receive_id_type: "open_id" 或 "chat_id"，默认 open_id
    """
    if token is None:
        [REDACTED_SECRET])
    try:
        if source.startswith("http://") or source.startswith("https://"):
            content = download_file(source)
        else:
            content = _read_local_file(source)

        file_key = upload_video(token, content)
        result = send_video_message(token, receive_id, file_key, receive_id_type)
        return {
            "success": True,
            "method": "feishu",
            "file_key": file_key,
            "receive_id_type": receive_id_type,
            "result": result
        }
    except requests.exceptions.HTTPError as e:
        try:
            err_body = e.response.json()
            code = err_body.get("code")
            msg = err_body.get("msg", "")
            err_msg = f"飞书错误码 {code}: {msg}"
        except Exception:
            err_msg = str(e)
        return {
            "success": False,
            "method": "cdn",
            "cdn_url": source if source.startswith("http") else None,
            "error": err_msg,
            "http_status": e.response.status_code if e.response is not None else None
        }
    except Exception as e:
        return {
            "success": False,
            "method": "cdn",
            "cdn_url": source if source.startswith("http") else None,
            "error": str(e)
        }


if __name__ == "__main__":
    if len(sys.argv) < 4:
        print("用法:")
        print("  python3 send_feishu.py image <receive_id> <file_or_url> [receive_id_type]")
        print("  python3 send_feishu.py video <receive_id> <file_or_url> [receive_id_type]")
        print("")
        print("参数说明:")
        print("  receive_id      - 接收方 ID（open_id: ou_xxx 或 chat_id: oc_xxx）")
        print("  receive_id_type  - 可选，默认 open_id。发送到群聊时指定 chat_id")
        print("")
        print("示例:")
        print("  # 发送给用户（默认 open_id）")
        print("  python3 send_feishu.py image ou_xxxxxxxx /tmp/a.jpg")
        print("")
        print("  # 发送到群聊")
        print("  python3 send_feishu.py image oc_xxxxxxxx /tmp/a.jpg chat_id")
        sys.exit(1)

    action = sys.argv[1]
    receive_id = sys.argv[2]
    source = sys.argv[3]
    receive_id_type = sys.argv[4] if len(sys.argv) > 4 else "open_id"

    if receive_id_type not in ("open_id", "chat_id"):
        print(f"receive_id_type 必须是 open_id 或 chat_id，当前值: {receive_id_type}")
        sys.exit(1)

    [REDACTED_SECRET])

    if action == "image":
        result = send_image(token, receive_id, source, receive_id_type)
    elif action == "video":
        result = send_video(token, receive_id, source, receive_id_type)
    else:
        print(f"Unknown action: {action}")
        sys.exit(1)

    print(json.dumps(result, ensure_ascii=False, indent=2))


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)