Skill: 316530790/libtv-skills (version 0.0.3)

--- name: libtv-skill description: agent-im 会话技能 - 通过 liblib.tv 的 AI 能力生成和编辑图片/视频。覆盖场景包括：生成（文生图、文生视频、图生视频、做动画、画一个xxx、来段xxx）、编辑修改（把xxx换成yyy、去掉xxx、加上xxx、改成xxx、调整xxx、局部修改、改镜头）、风格转换（风格迁移、转绘、换风格）、视频续写延长、复刻视频/TVC/宣传片、短剧/短漫剧生成、音乐MV生成、产品广告/展示片制作、分镜/故事板设计、教育视频/短视频制作。当用户提到 liblib、libtv、上传参考图/视频、查看生成进度时也应触发。关键判断：只要用户的请求涉及 AI 图片或视频的创作、生成、编辑、修改，无论措辞如何（如"画只猫"、"做个海报"、"把纸船换成爱心"、"这个视频帮我改一下"、"帮我复刻这段视频"、"用这首歌做个MV"、"一句话生成短剧"），都必须触发此技能。 user-invocable: true metadata: { "openclaw": { "emoji": "💬", "requires": { "bins": ["python3"], "env": ["LIBTV_ACCESS_KEY"] }, "primaryEnv": "LIBTV_ACCESS_KEY" } } --- # agent-im 会话（生图 / 生视频） 通过 agent-im 的 OpenAPI 创建会话、发送消息（生图、生视频、编辑视频等）、上传图片/视频文件，并查询会话消息进展。 LibTV 是 LiblibAI 推出的 AI 视频创作平台，同时为人类创作者和 Agent 设计。Agent 通过 Skill 入口理解任务、调用模型并自动编排工作流。 **平台核心能力：** - **生成**：文生图、文生视频、图生视频、视频续写 - **编辑**：局部修改、元素替换、镜头调整、风格迁移 - **复杂创作**：一句话生成完整短剧（剧本→分镜→成片）、复刻已有视频风格做 TVC/宣传片、用音乐生成 MV、产品展示片制作 - **模型**：Seedance 2.0、Kling 3.0/O3、Wan 2.6、NanoBanana、Midjourney、Seedream 5.0 等顶级模型 用户的所有创作和编辑需求都通过发送自然语言消息来完成，Agent 会自主编排工作流。复杂任务（短剧、MV）耗时较长，需耐心轮询。 ## 功能 1. **创建会话 / 发消息** - 创建新会话或向已有会话发送一条消息（如「生一个动漫视频」「把纸船换成爱心」） 2. **查询会话进展** - 根据 sessionId 拉取该会话的消息列表，用于轮询生图/生视频结果 3. **切换项目** - 将当前 accessKey 绑定的项目切换到新项目，后续 create_session 将使用新 projectUuid 4. **上传文件** - 上传图片或视频文件到 OSS，返回可访问的 OSS 地址（编辑已有视频/图片时需要先上传） 5. **下载结果** - 将会话中生成的图片/视频批量下载到本地，自动提取 URL 并命名 ## 前置要求 ```bash export LIBTV_ACCESS_KEY="your-access-key" ``` 可选：`OPENAPI_IM_BASE` 或 `IM_BASE_URL`，默认 `https://im.liblib.tv`。 无需安装额外依赖，仅使用 Python 标准库。 ## 使用方法 ### 1. 创建会话 / 发送消息 ```bash # 创建新会话并发送「生一个动漫视频」 python3 {baseDir}/scripts/create_session.py "生一个动漫视频" # 向已有会话发送消息 python3 {baseDir}/scripts/create_session.py "再生成一张风景图" --session-id SESSION_ID # 只创建/绑定会话，不发消息 python3 {baseDir}/scripts/create_session.py ``` ### 2. 查询会话进展 ```bash # 查询会话消息列表 python3 {baseDir}/scripts/query_session.py SESSION_ID # 增量拉取（只返回 seq 大于 N 的消息） python3 {baseDir}/scripts/query_session.py SESSION_ID --after-seq 5 # 附带项目地址（传入 create_session 返回的 projectUuid，结果中带 projectUrl） python3 {baseDir}/scripts/query_session.py SESSION_ID --project-id PROJECT_UUID ``` ### 3. 切换项目 ```bash # 切换当前 accessKey 绑定的项目（后续创建会话将使用新项目） python3 {baseDir}/scripts/change_project.py ``` ### 4. 上传文件 当用户提供了参考的文件地址时，进行上传，仅支持图片、视频，文件大小必须在200M以下。 ```bash # 上传图片 python3 {baseDir}/scripts/upload_file.py /path/to/image.png # 上传视频 python3 {baseDir}/scripts/upload_file.py /path/to/video.mp4 ``` ### 5. 下载结果 生成完成后，可以将会话中的所有图片/视频批量下载到本地。 ```bash # 从会话自动提取并下载所有结果 python3 {baseDir}/scripts/download_results.py SESSION_ID # 指定输出目录 python3 {baseDir}/scripts/download_results.py SESSION_ID --output-dir ~/Desktop/my_project # 指定文件名前缀（如 storyboard_01.png, storyboard_02.png ...） python3 {baseDir}/scripts/download_results.py SESSION_ID --prefix "storyboard" # 直接下载指定 URL 列表（不需要 session_id） python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ./output ``` ## 典型工作流 理解这些工作流，才能正确组合上面的脚本完成用户需求。 ### 场景 1：用户要求生成图片/视频（最常见） ``` 1. create_session.py "用户的描述" → 拿到 sessionId + projectUuid 2. 每隔 8 秒调用 query_session.py SESSION_ID --after-seq 0 轮询 3. 检查 messages：当出现 assistant 角色的消息且包含图片/视频 URL → 任务完成 4. 自动下载：download_results.py SESSION_ID --output-dir ~/Downloads/项目名 --prefix 有意义的前缀 5. 向用户展示：本地文件列表 + projectUrl（画布链接） ``` 生成完成后**自动执行下载**，不需要用户额外请求。下载目录和前缀根据任务语义自动命名（如分镜用 `storyboard`，角色设定用 `character` 等）。 ### 场景 2：用户提供图片/视频要求编辑修改（如"把纸船换成爱心"） ``` 1. upload_file.py /path/to/video.mp4 → 拿到 OSS URL 2. create_session.py "把四周的纸船都换成白色的纸爱心 参考视频：{oss_url}" 3. 后续同场景 1 的步骤 2-5 ``` 用户给了文件路径 + 编辑指令 = 先上传文件，再把编辑指令和 OSS URL 一起发送。 ### 场景 3：用户提供参考图/视频要求生成新内容 ``` 1. upload_file.py /path/to/ref.png → 拿到 OSS URL 2. create_session.py "根据参考图生成xxx，参考图：{oss_url}" 3. 后续同场景 1 的步骤 2-5 ``` ### 场景 4：在已有会话中追加新需求 ``` 1. create_session.py "新的描述" --session-id SESSION_ID 2. 后续同场景 1 的步骤 2-5 ``` ### 轮询策略 - **间隔**：每 8 秒查询一次 - **增量拉取**：首次用 `--after-seq 0`，后续用上次拿到的最大 seq 值 - **完成判断**：messages 中出现 assistant 消息且 content 包含结果 URL（图片/视频地址） - **超时**：连续轮询 3 分钟仍无结果，告知用户"生成时间较长，可稍后通过项目画布链接查看"，不再继续轮询 - **错误重试**：单次查询失败可重试 1 次，连续 3 次失败则停止并告知用户 ## 输出格式 **create_session** 返回： ```json { "projectUuid": [REDACTED_SECRET], "sessionId": "90f05e0c-...", "projectUrl": "https://www.liblib.tv/canvas?projectId=aa3ba04c5044477cb7a00a9e5bf3b4d0" } ``` **query_session** 返回： ```json { "messages": [ {"id": "msg-xxx", "role": "user", "content": "生一个动漫视频"}, {"id": "msg-yyy", "role": "assistant", "content": "..."} ], "projectUrl": "https://www.liblib.tv/canvas?projectId=..." } ``` （`projectUrl` 仅在传入 `--project-id` 时存在） **change_project** 返回： ```json { "projectUuid": "新项目UUID", "projectUrl": "https://www.liblib.tv/canvas?projectId=新项目UUID" } ``` **upload_file** 返回： ```json { "url": "https://libtv-res.liblib.art/claw/{projectUuid}/{uuid}.png" } ``` **download_results** 返回： ```json { "output_dir": [REDACTED_SECRET], "downloaded": ["/Users/xxx/Downloads/libtv_results/01.png", "..."], "total": 9 } ``` ## 最终向用户展示时（OpenClaw） - **视频地址**：来自 `query_session` 返回的 `messages` 中 assistant 消息的 content 或结果里的视频/图片 URL，即「返回的结果」。 - **项目地址**：使用 `create_session` 返回的 `projectUrl`，或自行拼接 `https://www.liblib.tv/canvas?projectId=` + `projectUuid`。查询进展时若传入 `--project-id PROJECT_UUID`，`query_session` 会直接返回 `projectUrl`，便于一并展示。 在任务完成时，同时给出：**视频/图片结果链接** + **项目画布链接（projectUrl）**。 过程中，不要给出 **项目画布链接（projectUrl）**。 ## 核心原则：用户侧不做创作，只做传话 你（用户侧 Agent）的职责是**搬运工**，不是创作者。后端有专门的 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt。你要做的只有三件事： 1. **上传**：用户给了本地文件 → `upload_file.py` 拿到 OSS URL 2. **传话**：把用户的原始描述 + OSS URL 原封不动发给 `create_session.py` 3. **取件**：轮询结果 → 下载到本地 → 展示给用户 **绝对不要做的事：** - 不要替用户扩写、润色、翻译 prompt（用户说"帮我推演分镜"，就直接传"帮我推演分镜"，不要自己先写个分镜表再逐条发） - 不要自行拆解任务步骤（如把"生成9张分镜图"拆成9次独立请求） - 不要自行编排镜头描述、剧情推演、风格分析 - 不要在消息中添加自己编的 prompt（如"超写实风格，电影级光影，8K分辨率"之类的描述词） 后端 Agent 对模型能力、参数配置、prompt 工程远比用户侧更专业。用户侧越俎代庖只会降低生成质量，换个弱模型更是灾难。 **正确示例：** ``` 用户说：「帮我推演后续的故事，来个分镜大爆炸，帮我出一个16:9的九宫格的图。新建一个任务。」 用户给了参考图：/path/to/ref.png → upload_file.py /path/to/ref.png → 拿到 oss_url → create_session.py "帮我推演后续的故事，来个分镜大爆炸，帮我出一个16:9的九宫格的图。参考图：{oss_url}" → 轮询 → 下载 → 展示 ``` **错误示例：** ``` ❌ 用户侧自己先写了个九宫格分镜表（对峙、交锋、危机...） ❌ 然后把自己编的描述发给后端 ❌ 或者拆成9次 create_session 分别发送 ``` ## 注意事项 - 鉴权方式为请求头 `Authorization: Bearer <LIBTV_ACCESS_KEY>` - 创建会话时若不传 `message`，仅创建/绑定会话，不会调用 SendMessage - 查询会话时可用 `--after-seq` 做增量拉取，便于轮询新消息（含 assistant 回复与生图/生视频结果） - 项目画布地址固定为：`https://www.liblib.tv/canvas?projectId=` + projectUuid - 切换项目后，Redis 缓存会更新，下次 create_session 将使用新的 projectUuid - 上传文件仅支持图片（image/*）和视频（video/*）类型，其他类型会被拒绝，文件大小须在 200MB 以下 - 上传返回的 OSS 地址格式为 `https://libtv-res.liblib.art/claw/{projectUuid}/{uuid}{ext}` - 生成过程中只告知用户"正在生成中"，不要提前给出 projectUrl；任务完成后再同时给出：**结果链接（图片/视频 URL）** + **项目画布链接（projectUrl）**

path: scripts/change_project.py
content: #!/usr/bin/env python3
"""切换当前 accessKey 绑定的项目：POST /openapi/session/change-project"""

import json
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from _common import change_project, build_project_url


def main():
    data = change_project()
    project_uuid = data.get("projectUuid", "")

    if not project_uuid:
        print("错误：未返回 projectUuid", file=sys.stderr)
        sys.exit(1)

    project_url = build_project_url(project_uuid)
    out = {
        "projectUuid": project_uuid,
        "projectUrl": project_url,
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/create_session.py
content: #!/usr/bin/env python3
"""创建会话 / 向会话发送消息（生图、生视频等）：POST /openapi/session"""

import argparse
import json
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from _common import create_session, build_project_url


def main():
    parser = argparse.ArgumentParser(
        description="创建会话或向已有会话发送消息（用于生图、生视频）",
        epilog="""
环境变量:
  LIBTV_ACCESS_KEY  必填，Bearer 鉴权
  OPENAPI_IM_BASE 或 IM_BASE_URL  可选，默认 https://im.liblib.tv

示例:
  # 创建新会话并发送「生一个动漫视频」
  python3 create_session.py "生一个动漫视频"

  # 向已有会话发送消息
  python3 create_session.py "再生成一张风景图" --session-id 90f05e0c-5d08-4148-be40-e30fc7c7bedf

  # 只创建/绑定会话，不发消息
  python3 create_session.py
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "message",
        nargs="?",
        default="",
        help="要发送的消息内容（生图/生视频描述等），不传则不调用 SendMessage",
    )
    parser.add_argument(
        "--session-id",
        default="",
        help="已有会话 ID，不传则创建新会话或返回已有默认会话",
    )
    args = parser.parse_args()

    data = create_session(session_id=args.session_id or "", message=args.message or "")
    project_uuid = data.get("projectUuid", "")
    session_id = data.get("sessionId", "")

    if not session_id:
        print("错误：未返回 sessionId", file=sys.stderr)
        sys.exit(1)

    project_url = build_project_url(project_uuid)
    out = {
        "projectUuid": project_uuid,
        "sessionId": session_id,
        "projectUrl": project_url,
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/download_results.py
content: #!/usr/bin/env python3
"""下载生成结果：从会话中提取所有图片/视频 URL 并批量下载到本地"""

import argparse
import json
import os
import re
import sys
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed

sys.path.insert(0, os.path.dirname(__file__))
from _common import query_session


def extract_urls_from_messages(messages):
    """从会话消息中提取所有图片和视频结果 URL"""
    urls = []
    url_pattern = re.compile(r'https://libtv-res\.liblib\.art/[^\s"\'<>]+\.(?:png|jpg|jpeg|webp|mp4|mov|webm)')

    for msg in messages:
        content = msg.get("content", "")
        if not content or not isinstance(content, str):
            continue

        # 从 task_result 中提取（toolmsg 返回的结果）
        if msg.get("role") == "tool":
            try:
                data = json.loads(content)
                task_result = data.get("task_result", {})
                for img in task_result.get("images", []):
                    preview = img.get("previewPath", "")
                    if preview:
                        urls.append(preview)
                for vid in task_result.get("videos", []):
                    preview = vid.get("previewPath", vid.get("url", ""))
                    if preview:
                        urls.append(preview)
            except (json.JSONDecodeError, AttributeError):
                pass

        # 从 assistant 文本消息中提取 URL
        if msg.get("role") == "assistant":
            found = url_pattern.findall(content)
            urls.extend(found)

    # 去重保序
    seen = set()
    unique = []
    for u in urls:
        if u not in seen:
            seen.add(u)
            unique.append(u)
    return unique


def download_file(url, filepath):
    """下载单个文件"""
    req = urllib.request.Request(url, headers={"User-Agent": "LibTV-Skill/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            with open(filepath, "wb") as f:
                while True:
                    chunk = resp.read(8192)
                    if not chunk:
                        break
                    f.write(chunk)
        return filepath, None
    except Exception as e:
        return filepath, str(e)


def main():
    parser = argparse.ArgumentParser(
        description="下载会话中生成的图片/视频到本地",
        epilog="""
使用方式:
  # 从会话自动提取并下载所有结果
  python3 download_results.py SESSION_ID

  # 指定输出目录
  python3 download_results.py SESSION_ID --output-dir ~/Desktop/my_project

  # 指定文件名前缀
  python3 download_results.py SESSION_ID --prefix "storyboard"

  # 直接下载指定 URL 列表
  python3 download_results.py --urls URL1 URL2 URL3 --output-dir ./output
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("session_id", nargs="?", default="", help="会话 ID，自动提取该会话所有生成结果的 URL")
    parser.add_argument("--urls", nargs="+", default=[], help="直接指定要下载的 URL 列表（不需要 session_id）")
    parser.add_argument("--output-dir", default="", help="输出目录（默认 ~/Downloads/libtv_results/）")
    parser.add_argument("--prefix", default="", help="文件名前缀（如 'storyboard' → storyboard_01.png）")
    parser.add_argument("--workers", type=int, default=5, help="并行下载线程数（默认 5）")
    args = parser.parse_args()

    # 收集 URL
    urls = list(args.urls)
    if args.session_id:
        data = query_session(args.session_id)
        messages = data.get("messages", [])
        extracted = extract_urls_from_messages(messages)
        urls.extend(extracted)

    if not urls:
        print(json.dumps({"error": "未找到可下载的图片/视频 URL", "downloaded": []}, ensure_ascii=False, indent=2))
        sys.exit(1)

    # 准备输出目录
    output_dir = args.output_dir or os.path.expanduser("~/Downloads/libtv_results")
    os.makedirs(output_dir, exist_ok=True)

    # 构建下载任务
    tasks = []
    for i, url in enumerate(urls, 1):
        ext = os.path.splitext(url.split("?")[0])[-1] or ".png"
        if args.prefix:
            filename = f"{args.prefix}_{i:02d}{ext}"
        else:
            filename = f"{i:02d}{ext}"
        filepath = os.path.join(output_dir, filename)
        tasks.append((url, filepath))

    # 并行下载
    results = []
    errors = []
    with ThreadPoolExecutor(max_workers=args.workers) as pool:
        futures = {pool.submit(download_file, url, fp): (url, fp) for url, fp in tasks}
        for future in as_completed(futures):
            fp, err = future.result()
            if err:
                errors.append({"file": fp, "error": err})
            else:
                results.append(fp)

    # 按文件名排序输出
    results.sort()

    output = {
        "output_dir": output_dir,
        "downloaded": results,
        "total": len(results),
    }
    if errors:
        output["errors"] = errors

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


if __name__ == "__main__":
    main()


path: scripts/query_session.py
content: #!/usr/bin/env python3
"""查询会话进展：GET /openapi/session/:sessionId，返回消息列表"""

import argparse
import json
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from _common import query_session, build_project_url


def main():
    parser = argparse.ArgumentParser(
        description="查询会话消息列表（会话进展）",
        epilog="""
环境变量:
  LIBTV_ACCESS_KEY  必填，Bearer 鉴权
  OPENAPI_IM_BASE 或 IM_BASE_URL  可选，默认 https://im.liblib.tv

示例:
  python3 query_session.py 90f05e0c-5d08-4148-be40-e30fc7c7bedf
  python3 query_session.py 90f05e0c-5d08-4148-be40-e30fc7c7bedf --after-seq 5
  python3 query_session.py SESSION_ID --project-id PROJECT_UUID   # 结果中附带 projectUrl
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("session_id", help="会话 ID（由 create_session 返回）")
    parser.add_argument(
        "--after-seq",
        type=int,
        default=0,
        help="只返回 seq 大于该值的消息，用于增量拉取（默认 0）",
    )
    parser.add_argument(
        "--project-id",
        default="",
        help="项目 ID（即 create_session 返回的 projectUuid），传入则结果中附带 projectUrl 便于展示",
    )
    args = parser.parse_args()

    data = query_session(args.session_id, after_seq=args.after_seq)
    messages = data.get("messages", [])

    out = {"messages": messages}
    if args.project_id:
        out["projectUrl"] = build_project_url(args.project_id)
    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/upload_file.py
content: #!/usr/bin/env python3
"""上传图片/视频到 OSS：POST /openapi/upload（multipart/form-data）"""

import argparse
import json
import mimetypes
import os
import sys
import uuid
import urllib.request
import urllib.error

sys.path.insert(0, os.path.dirname(__file__))
from _common import IM_BASE, ACCESS_KEY

# 允许的 MIME 类型前缀
ALLOWED_PREFIXES = ("image/", "video/")


def upload_file(file_path: str) -> dict:
    """
    上传本地文件到 agent-im OSS。
    返回 data: { url }。
    """
    if not os.path.isfile(file_path):
        print(f"错误：文件不存在: {file_path}", file=sys.stderr)
        sys.exit(1)

    # 检查 MIME 类型
    mime_type, _ = mimetypes.guess_type(file_path)
    if mime_type and not any(mime_type.startswith(p) for p in ALLOWED_PREFIXES):
        print(f"错误：不支持的文件类型: {mime_type}，仅支持图片和视频", file=sys.stderr)
        sys.exit(1)

    # 构建 multipart/form-data 请求体
    boundary = f"----PythonUpload{uuid.uuid4().hex}"
    filename = os.path.basename(file_path)

    body_parts = []

    # accessKey 字段
    body_parts.append(f"--{boundary}\r\n".encode())
    body_parts.append(b'Content-Disposition: form-data; name="accessKey"\r\n\r\n')
    body_parts.append(f"{ACCESS_KEY}\r\n".encode())

    # file 字段
    content_type = mime_type or "application/octet-stream"
    body_parts.append(f"--{boundary}\r\n".encode())
    body_parts.append(
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
    )
    body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
    with open(file_path, "rb") as f:
        body_parts.append(f.read())
    body_parts.append(b"\r\n")

    # 结束边界
    body_parts.append(f"--{boundary}--\r\n".encode())

    data = b"".join(body_parts)

    url = f"{IM_BASE.rstrip('/')}/openapi/upload"
    req = urllib.request.Request(
        url,
        data=data,
        method="POST",
        headers={
            "Authorization": f"Bearer {ACCESS_KEY}",
            "Content-Type": f"multipart/form-data; boundary={boundary}",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            return result.get("data", {})
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8") if e.fp else ""
        print(f"API 错误 {e.code}: {err_body}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        print(f"网络错误: {e.reason}", file=sys.stderr)
        sys.exit(1)


def main():
    parser = argparse.ArgumentParser(
        description="上传图片或视频文件到 OSS",
        epilog="""
环境变量:
  LIBTV_ACCESS_KEY  必填，Bearer 鉴权
  OPENAPI_IM_BASE 或 IM_BASE_URL  可选，默认 https://im.liblib.tv

示例:
  # 上传图片
  python3 upload_file.py /path/to/image.png

  # 上传视频
  python3 upload_file.py /path/to/video.mp4
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "file",
        help="要上传的图片或视频文件路径",
    )
    args = parser.parse_args()

    data = upload_file(args.file)
    oss_url = data.get("url", "")

    if not oss_url:
        print("错误：未返回 OSS 地址", file=sys.stderr)
        sys.exit(1)

    out = {"url": oss_url}
    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/_common.py
content: """agent-im OpenAPI 公共模块：创建会话、查询会话（鉴权为 Authorization: Bearer <access_key>）"""

import json
import os
import sys
import urllib.request
import urllib.error

# 默认 im 环境
IM_BASE = os.environ.get("OPENAPI_IM_BASE", os.environ.get("IM_BASE_URL", "https://im.liblib.tv"))
ACCESS_KEY = os.environ.get("LIBTV_ACCESS_KEY", "")

# 项目画布地址前缀，拼上 projectId 即项目地址
PROJECT_CANVAS_BASE = "https://www.liblib.tv/canvas?projectId="


def build_project_url(project_id: str) -> str:
    """根据 projectId（即 projectUuid）拼接项目画布地址"""
    if not project_id:
        return ""
    return PROJECT_CANVAS_BASE + project_id.strip()

if not ACCESS_KEY:
    print("错误：请设置 LIBTV_ACCESS_KEY 环境变量", file=sys.stderr)
    sys.exit(1)


def _headers():
    return {
        "Authorization": f"Bearer {ACCESS_KEY}",
        "Content-Type": "application/json",
    }


def api_post(path: str, body: dict) -> dict:
    """POST 请求 agent-im OpenAPI"""
    url = f"{IM_BASE.rstrip('/')}{path}"
    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=data,
        method="POST",
        headers=_headers(),
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8") if e.fp else ""
        print(f"API 错误 {e.code}: {err_body}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        print(f"网络错误: {e.reason}", file=sys.stderr)
        sys.exit(1)


def api_get(path: str) -> dict:
    """GET 请求 agent-im OpenAPI"""
    url = f"{IM_BASE.rstrip('/')}{path}"
    req = urllib.request.Request(url, method="GET", headers=_headers())
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8") if e.fp else ""
        print(f"API 错误 {e.code}: {err_body}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        print(f"网络错误: {e.reason}", file=sys.stderr)
        sys.exit(1)


def create_session(session_id: str = "", message: str = "") -> dict:
    """
    创建会话或向已有会话发消息。
    返回 data: { projectUuid, sessionId }。
    """
    body = {}
    if session_id:
        body["sessionId"] = session_id
    if message:
        body["message"] = message
    resp = api_post("/openapi/session", body)
    return resp.get("data", {})


def query_session(session_id: str, after_seq: int = 0) -> dict:
    """
    查询会话消息列表。
    返回 data: { messages: [...] }。
    """
    path = f"/openapi/session/{session_id}"
    if after_seq > 0:
        path += f"?afterSeq={after_seq}"
    resp = api_get(path)
    return resp.get("data", {})


def change_project() -> dict:
    """
    切换当前 accessKey 绑定的项目（调用 libtv 切换项目，后续 create_session 将使用新项目）。
    返回 data: { projectUuid }。
    """
    resp = api_post("/openapi/session/change-project", {})
    return resp.get("data", {})


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