Skill: esign-cn-open-source/esign-contract (version 1.0.5)

--- name: esign-contract metadata: {"openclaw":{"primaryEnv":"ESIGN_APP_ID"}} version: 1.0.5 description: > e签宝电子签名技能 — 支持 AI 生成任意类型的合同/协议并排版为 PDF， 上传已有文件发起电子签署，返回签署链接。 触发场景：用户要求生成、起草、撰写任何类型的合同或协议（如"写个借款合同"、 "帮我拟一份保密协议"、"起草租赁合同"），或要求对已有合同文件发起签署、 查询签署进度、撤销签署、下载签署文件、验证电子签名。 触发词：写合同、生成合同、起草合同、拟合同、合同排版、合同草稿、 发起签署、电子签名、签署进度、撤销签署、下载签署文件、验签、 验证签名、签署链接、e签宝、esign 不触发场景：用户仅询问合同法律条款含义、咨询签署平台选型、或讨论与 e签宝无关的其他电子签名产品。 --- # e签宝电子签名技能 > **编排型 Skill**：本 Skill 统一编排 e签宝电子合同的完整生命周期，涵盖合同生成、文件上传、签署发起、进度查询、撤销、下载及验签六个子流程（流程 A–F）。Agent 根据用户意图选择对应流程执行，各流程间可按需串联。 ## 凭证配置 **配置文件路径**：`~/.config/esign-contract/.env` ```ini ESIGN_APP_ID=your_app_id ESIGN_APP_SECRET=your_app_secret ESIGN_BASE_URL=base_url ``` **凭证预检（必须在首次调用 API 前执行）**：调用任何 e签宝 API 之前，先检查 `~/.config/esign-contract/.env` 是否存在且包含 `ESIGN_APP_ID`。若文件不存在或缺少必要字段，提示用户两种方式： 1. **直接输入**（推荐）：请用户在对话中提供 App ID 和 App Secret（格式如 `appId;appSecret`），由 Agent 用 Bash 自动写入配置文件，**默认使用正式环境**： ```bash mkdir -p ~/.config/esign-contract && cat > ~/.config/esign-contract/.env << 'EOF' ESIGN_APP_ID=用户提供的appId ESIGN_APP_SECRET=用户提供的appSecret ESIGN_BASE_URL=https://openapi.esign.cn EOF ``` 2. **手动配置**：登录 [e签宝开放平台](https://open.esign.cn) 获取凭证，手动编辑 `~/.config/esign-contract/.env` **环境策略**：默认使用正式环境（`https://openapi.esign.cn`）。若 API 调用返回认证错误或连接失败，询问用户是否为沙箱环境，是则将 `ESIGN_BASE_URL` 改为 `https://smlopenapi.esign.cn` > 凭证文件存放于用户主目录，不随 Skill 目录变动，不会被意外提交到代码仓库。 ## 环境初始化 脚本位于 SKILL.md 所在目录的 `scripts/` 子目录下，执行时使用脚本的完整绝对路径。 **首次执行任何脚本前，必须先确保虚拟环境就绪**（一条命令完成检查与初始化）： ```bash cd <SKILL目录> && (test -d scripts/.venv || python3 -m venv scripts/.venv) && scripts/.venv/bin/pip install -q -r scripts/requirements.txt ``` **Python 解释器**：始终使用虚拟环境解释器： - macOS/Linux：`scripts/.venv/bin/python3` - Windows：`scripts/.venv/Scripts/python.exe` > 中文 PDF 显示异常时安装 Noto CJK 字体，或放入 `~/.config/esign-contract/`（相对 SKILL.md 目录）。 ## 输出规范 - 脚本返回值、JSON 配置、执行命令等技术细节**禁止展示给用户** — 包括但不限于：脚本路径、命令行参数、JSON 响应体、fileId、坐标值。用户只需看到进度提示和最终结果 - **内部流程名称（流程 A/B/C/D/E/F）、步骤编号不展示给用户** - 每执行一个实际操作前，给出对应的进度提示。只提示实际执行的步骤，不套用固定模板。可选提示： - `📋 正在提取签署人信息...`（仅上传文件流程） - `📄 正在排版合同...`（仅 AI 生成合同流程） - `📤 正在上传...` - `🔍 正在定位签章位置...` - `✍️ 正在创建签署流程...` - 最终输出（签署流程发起成功）： ``` ✅ 签署流程已发起 — 合同名称 --- **甲方** 姓名（手机号） 🔗 签署链接 --- **乙方** 姓名（手机号） 🔗 签署链接 --- ⏳ 请将对应链接发送给签署方，点击即可完成签署 ``` - 查询流程输出： ``` 📋 签署进度 — 合同名称 状态：● 签署中 创建时间：2026年3月27日 14:30 **甲方** 姓名 ✅已签署 **乙方** 姓名 ⏳待签署 ``` - 撤销流程输出： ``` ⚠️ 签署流程已撤销 — 合同名称 ``` - 下载文件输出： ``` 📥 签署文件已下载 — 合同名称 📂 保存路径 ``` - 出错时参考 `references/error-handling.md` - 多步骤用 `&&` 串联，减少交互轮次 --- ## 流程 A：AI 生成合同 → 签署 1. 参考 `references/contract-generation.md` 收集信息并生成合同，记住实际写入的文件路径（写入系统临时目录，如 `/tmp/contract.md`（macOS/Linux）或 `%TEMP%\contract.md`（Windows），可能追加序号） > 用户确认合同内容时须确保所有字段已填充，不可带空字段进入排版 2. 排版生成 PDF：`run.py format <实际md路径> <对应pdf路径>` - 失败时检查 Markdown 是否含非法字符、字体是否缺失；告知用户错误原因并停止 3. 参考 `references/signing-guide.md` 执行签署（上传实际生成的 PDF → 定位签章 → 发起流程 → 获取链接） - 上传失败：提示网络或凭证问题，建议检查 `~/.config/esign-contract/.env` 配置 - 签章定位失败：提示关键字未找到，请用户确认合同中签章关键字 - 发起流程失败：展示错误码，参考 `references/error-handling.md` --- ## 流程 B：上传文件 → 签署 1. 提取文本：`run.py extract_text text "<文件路径>"`，识别签署方信息和签章关键字 - 失败时检查文件格式是否为 PDF/Word，或文件路径是否正确 2. 展示签署方信息让用户确认，缺失必填项要求补充 3. 参考 `references/signing-guide.md` 执行签署（上传 → 关键字定位 → 发起流程 → 获取链接） - 上传失败：提示网络或凭证问题，建议检查 `~/.config/esign-contract/.env` 配置 - 签章定位失败：提示关键字未找到，请用户确认文件中签章关键字 - 发起流程失败：展示错误码，参考 `references/error-handling.md` > 签署主体判断规则详见 `references/signing-guide.md`。 --- ## 流程 C：查询签署进度 不知道 flowId 时先执行 `run.py list_flows`，再执行 `run.py query_flow "<signFlowId>"`。展示：流程状态、各签署方状态、创建时间。 **签署方信息补全**：API 返回的签署方姓名可能为空（如沙箱环境未注册的手机号）。展示前先用 `run.py list_flows` 读取本地 `flow_history.json`，按手机号匹配补全姓名和角色。 - 查询失败：提示 flowId 不存在或无权限，建议用户确认流程 ID 是否正确。 --- ## 流程 D：撤销签署流程 **⚠️ 不可逆操作，必须用户确认。** 确认后：`run.py revoke_flow "<signFlowId>" "撤销原因"` - 失败时检查流程是否已完成或已撤销（已终态不可再撤销），展示错误码并告知用户。 --- ## 流程 E：下载签署完成文件 前提：流程状态为已完成。`run.py download_docs "<signFlowId>"`（不传输出目录时自动使用系统临时目录） - 失败时检查流程是否尚未完成，或下载路径是否有写权限；提示用户实际原因。 --- ## 流程 F：验证电子签名 参考 `references/signing-guide.md` 中的验签步骤。两种方式：提供本地 PDF 文件路径，或指定签署流程获取 fileId。 - 失败时检查文件是否为有效 PDF、flowId 是否存在；若证书过期或签名无效，如实展示结果，不做额外推断。 展示格式： ``` 🔍 电子签名验证结果 — 合同名称 📄 文件完整性：✅ 未被篡改 / ❌ 已被修改 签署人： 1. 名称 — ✅ 签名有效 / ❌ 签名无效 证书：xxx | 签署时间：xxxx年xx月xx日 xx:xx > 总结性结论 ``` --- ## 注意事项 - **发起签署**和**撤销签署**是不可逆操作，必须用户确认 - 所有脚本输出 JSON 格式，便于解析 - `~/.config/esign-contract/.env` 中的凭证切勿泄露或提交到代码仓库

path: banner.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200">
  <defs>
    <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:#0a0a1a;stop-opacity:1" />
      <stop offset="50%" style="stop-color:#1a1a3e;stop-opacity:1" />
      <stop offset="100%" style="stop-color:#0d0d2b;stop-opacity:1" />
    </linearGradient>
    <linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
      <stop offset="0%" style="stop-color:#4F46E5;stop-opacity:1" />
      <stop offset="100%" style="stop-color:#06B6D4;stop-opacity:1" />
    </linearGradient>
    <linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="0%">
      <stop offset="0%" style="stop-color:#4F46E5;stop-opacity:0.3" />
      <stop offset="50%" style="stop-color:#06B6D4;stop-opacity:0.15" />
      <stop offset="100%" style="stop-color:#4F46E5;stop-opacity:0" />
    </linearGradient>
  </defs>

  <!-- Background -->
  <rect width="800" height="200" fill="url(#bg)" rx="12"/>

  <!-- Grid pattern -->
  <g opacity="0.06">
    <line x1="0" y1="40" x2="800" y2="40" stroke="#fff" stroke-width="0.5"/>
    <line x1="0" y1="80" x2="800" y2="80" stroke="#fff" stroke-width="0.5"/>
    <line x1="0" y1="120" x2="800" y2="120" stroke="#fff" stroke-width="0.5"/>
    <line x1="0" y1="160" x2="800" y2="160" stroke="#fff" stroke-width="0.5"/>
    <line x1="100" y1="0" x2="100" y2="200" stroke="#fff" stroke-width="0.5"/>
    <line x1="200" y1="0" x2="200" y2="200" stroke="#fff" stroke-width="0.5"/>
    <line x1="300" y1="0" x2="300" y2="200" stroke="#fff" stroke-width="0.5"/>
    <line x1="400" y1="0" x2="400" y2="200" stroke="#fff" stroke-width="0.5"/>
    <line x1="500" y1="0" x2="500" y2="200" stroke="#fff" stroke-width="0.5"/>
    <line x1="600" y1="0" x2="600" y2="200" stroke="#fff" stroke-width="0.5"/>
    <line x1="700" y1="0" x2="700" y2="200" stroke="#fff" stroke-width="0.5"/>
  </g>

  <!-- Glow orbs -->
  <circle cx="650" cy="60" r="120" fill="#4F46E5" opacity="0.07"/>
  <circle cx="150" cy="150" r="100" fill="#06B6D4" opacity="0.05"/>

  <!-- Accent line -->
  <rect x="60" y="165" width="120" height="3" rx="1.5" fill="url(#accent)"/>

  <!-- Flow arrows -->
  <g opacity="0.25">
    <text x="520" y="170" font-family="system-ui,sans-serif" font-size="13" fill="#94a3b8" letter-spacing="3">
      DRAFT → FORMAT → SIGN
    </text>
  </g>

  <!-- Icon: contract/pen -->
  <g transform="translate(60, 40)" opacity="0.9">
    <rect x="0" y="0" width="36" height="44" rx="4" fill="none" stroke="url(#accent)" stroke-width="2"/>
    <line x1="8" y1="12" x2="28" y2="12" stroke="#4F46E5" stroke-width="1.5" opacity="0.6"/>
    <line x1="8" y1="20" x2="24" y2="20" stroke="#4F46E5" stroke-width="1.5" opacity="0.4"/>
    <line x1="8" y1="28" x2="20" y2="28" stroke="#4F46E5" stroke-width="1.5" opacity="0.3"/>
    <path d="M30 30 L38 22 L42 26 L34 34 Z" fill="url(#accent)"/>
    <path d="M29 35 L34 34 L30 30 Z" fill="#06B6D4" opacity="0.8"/>
  </g>

  <!-- Title -->
  <text x="60" y="130" font-family="system-ui,-apple-system,sans-serif" font-size="38" font-weight="700" fill="#ffffff" letter-spacing="-1">
    eSign Contract
  </text>
  <text x="378" y="130" font-family="system-ui,-apple-system,sans-serif" font-size="38" font-weight="300" fill="#94a3b8" letter-spacing="-1">
    Skill
  </text>

  <!-- Subtitle -->
  <text x="62" y="152" font-family="system-ui,-apple-system,sans-serif" font-size="14" fill="#64748b" letter-spacing="0.5">
    AI驱动的电子合同全流程Skill
  </text>
</svg>


path: README.md
content: # eSign Contract Skill

<p align="center">
  <img src="banner.svg" alt="eSign Contract Skill" width="800">
</p>

<p align="center">
  <b>一句话，完成完成起草到签署</b><br>
  自然语言生成合同 → 专业 PDF 排版 → e签宝电子签署 → 签署链接直达
</p>

<p align="center">
  <a href="https://www.esign.cn">e签宝官网</a> · <a href="https://open.esign.cn">开放平台</a> · <a href="https://open.esign.cn/doc">API 文档</a>
</p>

---

## Demo

```
你：帮我写一份电子合同软件采购合同，甲方张三，乙方李四，金额9.88万，有效期一年
```

```
📝 正在收集合同要素...
📄 正在排版合同...
📤 正在上传...
🔍 正在定位签章位置...
✍️ 正在创建签署流程...

✅ 签署流程已发起 — 借款合同

---

甲方  张三（138xxxx0001）
🔗 https://h5.esign.cn/sign/xxx

---

乙方  李四（139xxxx0002）
🔗 https://h5.esign.cn/sign/yyy

---

⏳ 请将对应链接发送给签署方，点击即可完成签署
```

## 功能

| | 能力 | 说明 |
|---|---|---|
| 📝 | **智能起草** | 任意类型合同，引导式收集信息，自动校验条款完整性 |
| 📄 | **专业排版** | 双栏布局、金额大小写并列、中文字体、正式 PDF 输出 |
| ✍️ | **电子签署** | 集成 e签宝 V3 API，自动定位签章，个人 & 企业签署 |
| 📋 | **进度管理** | 查询签署状态、撤销流程、下载已签文件 |
| 🔍 | **签名验证** | 校验签名有效性，检测文件是否被篡改 |
| 📎 | **多格式上传** | 支持 PDF / DOC / DOCX，自动转换后发起签署 |

## 快速开始

### 1. 安装 Skill

将 `eSign-contract` 目录放入 Agent 的 Skills 路径：

```bash
# macOS / Linux
cp -r eSign-contract ~/.claude/skills/
cp -r eSign-contract ~/.Codex/skills/
```

### 2. 开始使用

```
帮我写一份租赁合同
起草保密协议
对这个PDF发起签署
查一下签署进度
```

首次运行会自动创建 Python 虚拟环境并安装依赖。

## 使用场景

**起草合同** — "写个借款合同"、"起草租赁协议"、"拟一份保密协议"、"写个股权转让合同"

**上传签署** — "对这个合同发起签署"、"上传文件让双方签字"

**签署管理** — "查一下签署进度"、"撤销签署流程"、"下载已签文件"

**验证签名** — "验证这份合同的电子签名"、"检查文件是否被篡改"

> 支持任意合同类型：借款、租赁、劳动、保密协议（NDA）、股权转让、服务、采购、合作、加盟、知识产权许可……根据场景智能调整条款结构。

## 工作原理

```
用户输入 ──→ AI 起草合同（Markdown）──→ PDF 排版引擎 ──→ e签宝 API
                                                           │
                    ┌──────────────────────────────────────┘
                    │
                    ├── 上传文件，获取 fileId
                    ├── 关键字定位签章坐标
                    ├── 创建签署流程
                    └── 生成各方签署链接
```

## 项目结构

```
eSign-contract/
├── SKILL.md                     # Skill 主文件（流程编排）
├── references/
│   ├── contract-generation.md   # 合同生成规范
│   ├── signing-guide.md         # 签署执行指南
│   └── error-handling.md        # 错误处理参考
└── scripts/
    ├── run.py                   # 统一入口
    ├── esign_api.py             # e签宝接口封装
    ├── contract_formatter.py    # PDF 排版引擎
    └── requirements.txt         # Python 依赖
```


## 相关链接

| 资源 | 链接 |
|---|---|
| e签宝官网 | [esign.cn](https://www.esign.cn) |
| 开放平台 | [open.esign.cn](https://open.esign.cn) |
| API 文档 | [open.esign.cn/doc](https://open.esign.cn/doc) |

---

<p align="center">
  Powered by <a href="https://www.esign.cn">e签宝</a>
</p>


path: scripts/run.py
content: """统一入口脚本 — 路由命令到对应脚本执行。

用法:
    python3 run.py <command> [args...]
"""

import json
import os
import subprocess
import sys

_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

_COMMANDS = [
    "upload", "search_keyword", "create_flow", "sign_url",
    "query_flow", "list_flows", "revoke_flow", "download_docs",
    "verify", "save_flow", "file_status", "extract_text", "format",
]


def _resolve_command(cmd, args):
    """将统一命令路由到对应脚本和参数。"""
    if cmd == "extract_text":
        return ["extract_text.py"] + list(args)
    elif cmd == "format":
        return ["format_contract.py"] + list(args)
    else:
        return ["esign_api.py", cmd] + list(args)


def main():
    if len(sys.argv) < 2:
        print(json.dumps({"error": "用法: python3 run.py <command> [args...]", "commands": _COMMANDS}))
        sys.exit(1)

    cmd = sys.argv[1]
    args = sys.argv[2:]

    script_args = _resolve_command(cmd, args)
    script_path = os.path.join(_SCRIPT_DIR, script_args[0])

    python = sys.executable
    full_cmd = [python, script_path] + script_args[1:]

    result = subprocess.run(full_cmd, capture_output=True, text=True)

    if result.stdout:
        print(result.stdout, end="")

    if result.stderr:
        print(result.stderr, end="", file=sys.stderr)

    sys.exit(result.returncode)


if __name__ == "__main__":
    main()


path: scripts/esign_api.py
content: """e签宝 API 客户端封装。

用法（由 Agent 通过 Bash 调用）:
    python3 esign_api.py upload <file_path>
    python3 esign_api.py search_keyword <file_id> <keyword>
    python3 esign_api.py create_flow <json_config_path>
    python3 esign_api.py sign_url <sign_flow_id> <operator_id>
    python3 esign_api.py query_flow <sign_flow_id>
    python3 esign_api.py revoke_flow <sign_flow_id> <reason>
    python3 esign_api.py download_docs <sign_flow_id> [output_dir]
    python3 esign_api.py verify <file_id_or_path> [sign_flow_id]
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import tempfile
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# 凭证加载优先级：
# 1. ~/.config/esign-contract/.env（推荐，路径固定，不受 Skill 安装目录影响）
# 2. 系统环境变量（ESIGN_APP_ID / ESIGN_APP_SECRET / ESIGN_BASE_URL）
# 不再依赖 Skill 目录下的 .env，避免相对路径因安装/执行目录不确定而失效
_CONFIG_DIR = Path.home() / ".config" / "esign-contract"
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
load_dotenv(_CONFIG_DIR / ".env")


def _atomic_write_json(path: str, data: dict):
    """原子写入 JSON 文件：先写临时文件，再 os.replace 替换，避免并发读到半写入数据。"""
    dir_name = os.path.dirname(path) or "."
    fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        os.replace(tmp_path, path)
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


class ESignAPIError(Exception):
    """e签宝 API 调用错误。"""
    def __init__(self, code, message):
        self.code = code
        self.message = message
        super().__init__(f"e签宝 API 错误 [{code}]: {message}")


class ESignClient:
    """e签宝 API 客户端，支持签名鉴权和 Token 鉴权两种方式。"""

    def __init__(self, **kwargs):
        self.app_id = kwargs.get("app_id") or os.environ["ESIGN_APP_ID"]
        self.app_secret = kwargs.get("app_secret") or os.environ["ESIGN_APP_SECRET"]
        self.base_url = kwargs.get("base_url") or os.environ.get(
            "ESIGN_BASE_URL", "https://openapi.esign.cn"
        )
        self.base_url = self.base_url.rstrip("/")
        self._token_cache_path = kwargs.get("token_cache_path") or str(
            _CONFIG_DIR / "token_cache.json"
        )
        # 鉴权方式：auto（默认，先签名后 token）、signature、token
        self._auth_mode = kwargs.get("auth_mode") or os.environ.get(
            "ESIGN_AUTH_MODE", "auto"
        )

    # ── HmacSHA256 签名鉴权 ──────────────────────────────────────

    @staticmethod
    def _content_md5(body: bytes) -> str:
        if not body:
            return ""
        return base64.b64encode(hashlib.md5(body).digest()).decode()

    def _sign(self, method: str, path: str, content_type: str, content_md5: str) -> str:
        accept = "*/*"
        parts = [method.upper(), accept, content_md5, content_type, ""]
        string_to_sign = "\n".join(parts) + "\n" + path
        signature = hmac.new(
            self.app_secret.encode(), string_to_sign.encode(), hashlib.sha256
        ).digest()
        return base64.b64encode(signature).decode()

    def _signature_headers(self, method: str, path: str, body: bytes = b"") -> dict:
        content_type = "application/json; charset=UTF-8" if body else ""
        content_md5 = self._content_md5(body)
        sig = self._sign(method, path, content_type, content_md5)
        headers = {
            "X-Tsign-Open-App-Id": self.app_id,
            "X-Tsign-Open-Ca-Signature": sig,
            "Accept": "*/*",
        }
        if content_md5:
            headers["Content-MD5"] = content_md5
        if content_type:
            headers["Content-Type"] = content_type
        return headers

    # ── OAuth Token 鉴权 ─────────────────────────────────────────

    def _get_token(self) -> str:
        cached = self._read_token_cache()
        if cached:
            return cached
        return self._fetch_new_token()

    def _read_token_cache(self):
        try:
            with open(self._token_cache_path, "r") as f:
                data = json.load(f)
            if data.get("expires_at", 0) > time.time() + 300:
                return data["token"]
        except (FileNotFoundError, json.JSONDecodeError, KeyError):
            pass
        return None

    def _fetch_new_token(self) -> str:
        resp = requests.get(
            f"{self.base_url}/v1/oauth2/access_token",
            params={
                "appId": self.app_id,
                "secret": self.app_secret,
                "grantType": "client_credentials",
            },
        )
        result = resp.json()
        if result.get("code") != 0:
            raise ESignAPIError(result.get("code"), result.get("message", "获取 token 失败"))
        [REDACTED_SECRET]"data"]["token"]
        expires_in = int(result["data"]["expiresIn"])
        # expiresIn 是毫秒时间戳
        expires_at = expires_in / 1000 if expires_in > 1e12 else time.time() + expires_in
        _atomic_write_json(self._token_cache_path, {"token": token, "expires_at": expires_at})
        return token

    def _token_headers(self, body: bytes = b"") -> dict:
        headers = {
            "X-Tsign-Open-App-Id": self.app_id,
            "X-Tsign-Open-Token": self._get_token(),
        }
        if body:
            headers["Content-Type"] = "application/json; charset=UTF-8"
        return headers

    # ── 统一请求 ─────────────────────────────────────────────────

    def _request(self, method: str, path: str, **kwargs) -> dict:
        url = f"{self.base_url}{path}"
        body = b""
        if "json" in kwargs:
            json_data = kwargs.pop("json")
            if json_data is not None:
                body = json.dumps(json_data, ensure_ascii=False).encode("utf-8")
                kwargs["data"] = body

        modes = (
            ["signature", "token"] if self._auth_mode == "auto"
            else [self._auth_mode]
        )

        last_error = None
        for mode in modes:
            if mode == "signature":
                headers = self._signature_headers(method, path, body)
            else:
                headers = self._token_headers(body)

            for attempt in range(3):
                resp = requests.request(method, url, headers=headers, timeout=30, **kwargs)
                if resp.status_code == 429:
                    time.sleep(2 ** attempt)
                    continue
                result = resp.json()
                if result.get("code") == 401 and mode == "signature" and len(modes) > 1:
                    last_error = result
                    break  # 签名鉴权失败，尝试 token
                if result.get("code") != 0:
                    raise ESignAPIError(result.get("code"), result.get("message", "未知错误"))
                # 签名鉴权成功，锁定模式
                if self._auth_mode == "auto" and mode == "signature":
                    self._auth_mode = "signature"
                elif self._auth_mode == "auto" and mode == "token":
                    self._auth_mode = "token"
                return result.get("data", {})
            else:
                if mode == modes[-1]:
                    raise ESignAPIError(-1, "请求超过重试次数限制")

        raise ESignAPIError(
            last_error.get("code") if last_error else -1,
            last_error.get("message", "鉴权失败") if last_error else "鉴权失败",
        )

    # ── File Operations ───────────────────────────────────────────

    def upload_file(self, file_path: str) -> str:
        """上传本地文件到 e签宝，返回 fileId。"""
        p = Path(file_path)
        if not p.exists():
            raise FileNotFoundError(f"文件不存在: {file_path}")

        file_size = p.stat().st_size
        if file_size > 50 * 1024 * 1024:
            print(json.dumps({"warning": f"文件较大（{file_size // 1024 // 1024}MB），上传可能需要较长时间"}),
                  file=sys.stderr)
        content_type_map = {
            ".pdf": "application/pdf",
            ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            ".doc": "application/msword",
            ".odt": "application/vnd.oasis.opendocument.text",
        }
        content_type = content_type_map.get(p.suffix.lower(), "application/octet-stream")

        # 计算文件 MD5
        file_bytes = p.read_bytes()
        file_md5 = base64.b64encode(hashlib.md5(file_bytes).digest()).decode()

        # Step 1: 获取上传地址
        upload_params = {
            "fileName": p.name,
            "fileSize": file_size,
            "contentType": content_type,
            "contentMd5": file_md5,
        }
        # 非 PDF 文件需要服务端转换为 PDF
        if p.suffix.lower() != ".pdf":
            upload_params["convertToPDF"] = True
        data = self._request("POST", "/v3/files/file-upload-url", json=upload_params)
        file_id = data["fileId"]
        upload_url = data["fileUploadUrl"]

        # Step 2: PUT 文件到上传地址
        resp = requests.put(
            upload_url,
            data=file_bytes,
            headers={
                "Content-Type": content_type,
                "Content-MD5": file_md5,
            },
            timeout=60,
        )
        if resp.status_code not in (200, 201):
            raise ESignAPIError(-1, f"文件上传失败，HTTP {resp.status_code}")

        return file_id

    def search_keyword(self, file_id: str, keyword: str) -> list:
        """检索文件中关键字的坐标位置。

        V3 接口路径: POST /v3/files/{fileId}/keyword-positions
        注意: 返回坐标为关键字第一个字的左下角位置。
        返回: keywordPositions[].positions[].coordinates[].{positionX, positionY}
        """
        data = self._request("POST", f"/v3/files/{file_id}/keyword-positions", json={
            "keywords": [keyword],
        })
        keyword_positions = data.get("keywordPositions", [])
        matched_positions = []
        for item in keyword_positions:
            if not item.get("searchResult"):
                continue
            if not item.get("positions"):
                continue
            matched_positions.append(item)
        return matched_positions

    @staticmethod
    def calc_seal_position(keyword_x: float, keyword_y: float,
                           keyword_len: int = 5, font_size: float = 12,
                           seal_width: float = 159, seal_height: float = 159) -> dict:
        """根据关键字坐标计算签章定位坐标。

        关键字坐标（keyword API 返回）是第一个字左下角位置，PDF 坐标系（Y 从底部往上）。
        signFieldPosition 的坐标也是 PDF 坐标系，positionY 为签章区域的底边 Y。
        需要偏移使签章居中覆盖关键字区域。

        Args:
            keyword_x: 关键字第一个字左下角 X
            keyword_y: 关键字第一个字左下角 Y（PDF 坐标，从页面底部起算）
            keyword_len: 关键字字符数（用于计算关键字宽度）
            font_size: 关键字字号（pt）
            seal_width: 签章宽度（pt, 默认159≈56mm）
            seal_height: 签章高度（pt, 默认159≈56mm）

        Returns:
            {"positionX": float, "positionY": float} 签章定位坐标
        """
        keyword_width = keyword_len * font_size
        # X: 签章左边紧贴关键字右侧
        seal_x = keyword_x + keyword_width
        # Y: 签章垂直居中对齐关键字文本中心
        seal_y = keyword_y + font_size / 2 - seal_height / 2
        return {"positionX": round(seal_x, 2), "positionY": round(seal_y, 2)}

    def create_sign_flow(self, flow_config: dict) -> str:
        """基于文件发起签署流程，返回 signFlowId。"""
        data = self._request(
            "POST", "/v3/sign-flow/create-by-file", json=flow_config
        )
        return data["signFlowId"]

    def get_sign_url(self, sign_flow_id: str, operator_account: str,
                      org_info: dict = None) -> dict:
        """获取签署页面链接。

        Args:
            sign_flow_id: 签署流程ID
            operator_account: 经办人手机号/邮箱
            org_info: 企业信息（可选），格式 {"orgName": "xxx"} 或 {"orgId": "xxx"}
        """
        body = {
            "needLogin": True,
            "urlType": 2,
            "operator": {
                "psnAccount": operator_account,
            },
        }
        if org_info:
            body["organization"] = org_info
        return self._request(
            "POST",
            f"/v3/sign-flow/{sign_flow_id}/sign-url",
            json=body,
        )

    def query_flow(self, sign_flow_id: str) -> dict:
        """查询签署流程详情。"""
        return self._request("GET", f"/v3/sign-flow/{sign_flow_id}/detail")

    def revoke_flow(self, sign_flow_id: str, reason: str) -> dict:
        """撤销签署流程。

        仅签署中的流程可撤销，撤销后流程终止变为已撤销状态。
        注意：签署方已完成签字盖章后撤销需获得全部已完成盖章签署方同意。

        Args:
            sign_flow_id: 签署流程ID
            reason: 撤销原因
        """
        return self._request(
            "POST",
            f"/v3/sign-flow/{sign_flow_id}/revoke",
            json={"revokeReason": reason},
        )

    def download_flow_documents(self, sign_flow_id: str, output_dir: str = "") -> list:
        """下载签署完成的文件。

        前提：流程状态必须为已完成（signFlowStatus=2）。
        先获取下载地址，再下载文件到本地。

        Args:
            sign_flow_id: 签署流程ID
            output_dir: 输出目录，默认使用系统临时目录（tempfile.gettempdir()）

        Returns:
            list: 下载的文件信息 [{"fileId": ..., "fileName": ..., "filePath": ...}]
        """
        # Step 1: 检查流程状态
        detail = self.query_flow(sign_flow_id)
        status = detail.get("signFlowStatus")
        status_desc = detail.get("signFlowDescription", "")
        if status != 2:
            raise ESignAPIError(
                -1,
                f"流程当前状态为「{status_desc}」（status={status}），仅已完成的流程可下载签署文件",
            )

        # Step 2: 获取下载地址
        data = self._request(
            "GET",
            f"/v3/sign-flow/{sign_flow_id}/file-download-url",
        )
        files = data.get("files", [])
        if not files:
            raise ESignAPIError(-1, "未找到可下载的签署文件")

        # Step 3: 下载文件到本地
        if not output_dir:
            output_dir = tempfile.gettempdir()
        os.makedirs(output_dir, exist_ok=True)
        downloaded = []
        for f in files:
            file_name = f.get("fileName", f"signed_{f.get('fileId', 'unknown')}.pdf")
            download_url = f.get("downloadUrl") or f.get("fileDownloadUrl")
            if not download_url:
                continue
            file_path = os.path.join(output_dir, file_name)
            resp = requests.get(download_url, timeout=60)
            if resp.status_code != 200:
                raise ESignAPIError(-1, f"文件下载失败: {file_name}，HTTP {resp.status_code}")
            with open(file_path, "wb") as out:
                out.write(resp.content)
            downloaded.append({
                "fileId": f.get("fileId"),
                "fileName": file_name,
                "filePath": file_path,
                "fileSize": len(resp.content),
            })

        return downloaded

    def query_file_status(self, file_id: str) -> dict:
        """查询文件上传状态。"""
        return self._request("GET", f"/v3/files/{file_id}")

    def verify_signature(self, file_id: str, sign_flow_id: str = None) -> dict:
        """核验合同文件签名有效性。

        解析 PDF 文件中签署者数字证书信息，验证内容或签名是否被篡改。

        Args:
            file_id: 待验签的文件 ID（若为本地文件，需先通过 upload 获取 fileId）
            sign_flow_id: 签署流程 ID（可选，指定后可关联流程验证）

        Returns:
            dict: 验签结果，包含签名信息、证书信息、篡改检测结果等
        """
        body = {}
        if sign_flow_id:
            body["signFlowId"] = sign_flow_id
        return self._request("POST", f"/v3/files/{file_id}/verify", json=body if body else None)


_FLOW_HISTORY_PATH = str(_CONFIG_DIR / "flow_history.json")


def save_flow_record(sign_flow_id: str, contract_name: str, parties: list):
    """记录签署流程到本地历史。

    parties 支持两种格式（向后兼容）：
    - 字符串列表: ["甲方名称", "乙方名称"]  → 自动转为 {"name": x, "role": ""}
    - 结构化列表: [{"name": "张三", "role": "甲方", "phone": "138xxx"}]
    """
    from datetime import datetime

    normalized = []
    for p in parties:
        if isinstance(p, str):
            normalized.append({"name": p, "role": "", "phone": ""})
        elif isinstance(p, dict):
            normalized.append({
                "name": p.get("name", ""),
                "role": p.get("role", ""),
                "phone": p.get("phone", ""),
            })
        else:
            normalized.append({"name": str(p), "role": "", "phone": ""})

    history = _load_flow_history()
    history["flows"].append({
        "signFlowId": sign_flow_id,
        "contractName": contract_name,
        "parties": normalized,
        "createdAt": datetime.now().isoformat(),
    })
    _atomic_write_json(_FLOW_HISTORY_PATH, history)


def list_flow_records() -> list:
    """列出所有签署流程历史记录。"""
    return _load_flow_history().get("flows", [])


def _load_flow_history() -> dict:
    try:
        with open(_FLOW_HISTORY_PATH, "r") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {"flows": []}


# ── CLI Entry Point ───────────────────────────────────────────

def _cli():
    """命令行入口，供 Agent 通过 Bash 调用。"""
    if len(sys.argv) < 2:
        print(json.dumps({"error": "用法: python3 esign_api.py <command> [args...]"}))
        sys.exit(1)

    client = ESignClient()
    cmd = sys.argv[1]

    try:
        if cmd == "upload":
            file_path = sys.argv[2]
            file_id = client.upload_file(file_path)
            print(json.dumps({"fileId": file_id}))

        elif cmd == "search_keyword":
            file_id, keyword = sys.argv[2], sys.argv[3]
            result = client.search_keyword(file_id, keyword)
            print(json.dumps({"keywordPositions": result}, ensure_ascii=False))

        elif cmd == "create_flow":
            config_path = sys.argv[2]
            with open(config_path, "r") as f:
                config = json.load(f)
            flow_id = client.create_sign_flow(config)
            print(json.dumps({"signFlowId": flow_id}))

        elif cmd == "sign_url":
            flow_id, operator_account = sys.argv[2], sys.argv[3]
            org_info = None
            if len(sys.argv) > 4:
                org_info = json.loads(sys.argv[4])
            result = client.get_sign_url(flow_id, operator_account, org_info)
            print(json.dumps(result, ensure_ascii=False))

        elif cmd == "query_flow":
            flow_id = sys.argv[2]
            result = client.query_flow(flow_id)
            print(json.dumps(result, ensure_ascii=False))

        elif cmd == "file_status":
            file_id = sys.argv[2]
            result = client.query_file_status(file_id)
            print(json.dumps(result, ensure_ascii=False))

        elif cmd == "save_flow":
            flow_id, name = sys.argv[2], sys.argv[3]
            # Support JSON array for structured parties: '[{"name":"x","role":"甲方","phone":"138xxx"}]'
            if len(sys.argv) == 5 and sys.argv[4].startswith('['):
                parties = json.loads(sys.argv[4])
            else:
                parties = sys.argv[4:]
            save_flow_record(flow_id, name, parties)
            print(json.dumps({"saved": True}))

        elif cmd == "list_flows":
            flows = list_flow_records()
            print(json.dumps({"flows": flows}, ensure_ascii=False))

        elif cmd == "revoke_flow":
            flow_id = sys.argv[2]
            reason = sys.argv[3] if len(sys.argv) > 3 else "用户主动撤销"
            result = client.revoke_flow(flow_id, reason)
            print(json.dumps({"revoked": True, "signFlowId": flow_id, **result}, ensure_ascii=False))

        elif cmd == "download_docs":
            flow_id = sys.argv[2]
            output_dir = sys.argv[3] if len(sys.argv) > 3 else tempfile.gettempdir()
            files = client.download_flow_documents(flow_id, output_dir)
            print(json.dumps({"files": files}, ensure_ascii=False))

        elif cmd == "verify":
            file_id_or_path = sys.argv[2]
            sign_flow_id = sys.argv[3] if len(sys.argv) > 3 else None
            # 如果传入的是文件路径，先上传获取 fileId
            if os.path.exists(file_id_or_path):
                file_id = client.upload_file(file_id_or_path)
            else:
                file_id = file_id_or_path
            result = client.verify_signature(file_id, sign_flow_id)
            print(json.dumps(result, ensure_ascii=False))

        else:
            print(json.dumps({"error": f"未知命令: {cmd}"}))
            sys.exit(1)

    except ESignAPIError as e:
        print(json.dumps({"error": e.message, "code": e.code}, ensure_ascii=False))
        sys.exit(1)
    except Exception as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False))
        sys.exit(1)


if __name__ == "__main__":
    _cli()


path: scripts/requirements.txt
content: weasyprint>=60.0,<63.0
pydyf==0.11.0
pdfplumber>=0.10.0,<1.0
python-docx>=1.0.0,<2.0
requests>=2.31.0,<3.0
python-dotenv>=1.0.0,<2.0
pytest>=8.0.0,<9.0


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