Skill: naive-white-expert/ai-phone-call (version 1.0.4)

--- name: ai phone call description: 一款支持多场景和中国电话号码的AI外呼助手，仅需一句话描述外呼场景，AI即可结合情境实时生成对话并呼出，外呼结束后即可查看通话状态、通话记录、通话小结等。An AI outbound assistant supporting diverse scenarios and Chinese phone numbers. Simply describe your scenario in one sentence, and the AI will generate real-time, context-aware dialogue and place the call. Post-call, you can instantly review call status, transcripts, and summaries. Zero-Configuration Entry: No complex setup or scripting required. Simply input your mission goal and launch your outbound task immediately. Human-like Interaction: Real-time, context-aware dialogue generation ensures smooth and natural calling experiences with seamless multi-turn conversations. Cost-Efficient: New users enjoy 10 FREE calls. version: 3.2.9 tags: [communication, telephony, voice, automation, ai, productivity, business, scheduling, china-region, local-service, voice-ai, auto-dialer, outbound-call, smart-assistant, china-optimized, mandarin-support, efficiency, no-code, automated-workflow, restaurant-booking, notification] author: skill外呼团队 license: MIT homepage: https://www.skill.black metadata: openclaw: requires: bins: ["uv"] env: - OUTBOUND_API_KEY credentials: - name: OUTBOUND_API_KEY description: "API密钥，从 https://www.skill.black 申请" required: true - name: OUTBOUND_BASE_URL description: "API地址，默认 https://www.skill.black" required: false --- # Outbound AI Call - 智能外呼服务 用户有打电话意图 → 意图提取 → 信息收集 → 确认 → 调用外呼 → 检查结果 → 补充信息（如需） --- ## ⚠️ 隐私声明与用户确认 **重要：在调用外呼接口前，必须向用户展示确认卡片并获得明确同意。** ### 数据收集与传输 本 skill 会收集以下信息并发送到 Outbound AI 外呼服务： | 数据类型 | 用途 | 是否传输 | 是否本地存储 | |----------|------|----------|--------------| | 电话号码 | 外呼目标号码 | ✓ | ✓ | | 对话上下文 | AI 理解通话目的 | ✓ | ✓ | | 通话记录 | 状态和日志 | - | ✓ | ### 第三方服务 - **API 提供方**：Outbound AI Call - **API 端点**：`https://www.skill.black` - **隐私政策**：https://www.skill.black/privacy - **API Key 申请**：https://www.skill.black ### 本地数据保留 - 请求记录：`memory/skills/requests.jsonl` - 通话日志：`memory/skills/costs.jsonl` - 这两个文件包含 PII（电话号码、对话内容），用户可随时删除 ### 用户确认流程 在信息收集完毕后，**必须**展示确认卡片： > **📞 外呼确认** > > 对象：{Who} > 电话：{Phone} > 目的：{What} > > ⚠️ 以下信息将发送到 Outbound AI 外呼服务： > • 电话号码：{Phone} > • 对话内容：{对话摘要} > > 确认拨打？（回复"确认"或"取消"） **仅在用户明确确认后才执行外呼。** --- ## 🎯 触发条件 当用户表达以下意图时触发： > "帮我给xxx打个电话预约个位子" > "帮我问下我父亲最近身体怎么样" > "给客户打电话确认预约时间" **关键词**：打电话、致电、外呼、电话通知、电话确认 --- ## 📋 步骤 1：意图提取 从用户输入中提取核心信息： | 要素 | 说明 | 示例 | |------|------|------| | **Who** | 打给谁（名称/关系/机构） | 蜀九香、客户、餐厅 | | **Phone** | 电话号码 | 18033009923 | | **What** | 外呼目的 | 预约位子、活动、确认时间 | --- ## 📝 步骤 2：信息收集 **目标：一次收集尽可能多的信息，减少后续追问次数。** **重要**：本 skill 采用通用信息框架 + 后端动态追问模式。后端会根据外呼目的自动判断需要收集的信息，并在缺少参数时返回追问要求。 ### 信息收集清单 #### 基础信息（必问） | 信息项 | 说明 | 追问方式 | |--------|------|----------| | **Who** | 被叫方名称/关系 | "请问打给谁？" | | **Phone** | 电话号码 | "请问对方电话是多少？" | | **What** | 外呼目的 | "请问打电话主要想说什么？" | #### 时间信息（涉及时间时追问） | 信息项 | 适用场景 | 追问方式 | |--------|----------|----------| | **When** | 预约、提醒、限时活动 | "请问是什么时间？" | | **Deadline** | 限时事项 | "有截止时间吗？" | --- ### 信息收集策略 #### 按场景智能追问 **预约类**（预约位子、预约安装、预约服务） > 已了解：{Who}，{What} > 追问： > 1. 请问预约什么时间？ > 2. 请问预约在哪个地点？（如不明确） > 3. 请问几个人/需要什么服务？ > 4. 有什么特殊要求吗？ **活动类**（活动通知、产品推广） > 已了解：{Who}，{What} > 追问： > 1. 请问是什么产品/活动？ > 2. 有什么核心卖点或优惠吗？ > 3. 活动时间是多久？ --- ## ✅ 步骤 3：信息确认（必须） **⚠️ 重要：在调用外呼接口前，必须向用户展示确认卡片并获得明确同意。** 收集完毕后，展示确认卡片： > **📞 外呼确认** > > 对象：{Who}（{ContactName}） > 电话：{Phone} > 目的：{What} > > 详细信息： > • 时间：{When} > • 地点：{Where} > • 具体事项：{Details} > > ⚠️ 以下信息将发送到 Outbound AI 外呼服务： > • 电话号码：{Phone} > • 对话内容：{对话摘要} > > 确认拨打？（回复"确认"或"取消"） **用户确认后才可进入下一步。如果用户取消，则终止流程。** --- ## 🚀 步骤 4：调用外呼接口 ### 构建请求参数 | 参数 | 类型 | 必填 | 说明 | 示例值 | |------|------|------|------|--------| | **phone** | string | ✓ | 外呼目标电话号码 | `"18033009923"` | | **messages** | string[] | ✓ | 对话上下文 | `["用户: ...", "助手: ..."]` | | **mustOutbound** | boolean | - | 缺少选填参数时是否强制外呼 | `false` | **mustOutbound 使用场景**： - 用户明确要求立即外呼，即使信息不完整 - 例如：用户说"不用问了，直接打吧" **messages 说明**： - 包含用户与助手之间的完整对话上下文 - 后端根据上下文生成 prompt - 格式为字符串数组：`"角色: 内容"` ### 执行调用 **推荐：Python 脚本（跨平台）** > 执行命令： > > `uv run scripts/make-call.py --phone "{电话}" --messages '["用户: ...", "助手: ...", "用户: ..."]'` > > **强制外呼**： > > `uv run scripts/make-call.py --phone "{电话}" --messages '["用户: ..."]' --must-outbound` --- ## 🔄 步骤 5：处理返回结果 ### 返回格式 **成功响应示例**： | 字段 | 值 | 说明 | |------|------|------| | code | `"10000"` | 成功状态码 | | result | `"成功"` | 结果描述 | | data | `"12331@e561a439..."` | request_id | **失败响应示例**： | 字段 | 值 | 说明 | |------|------|------| | code | `"190001"` | 风控拦截 | | result | `"风控校验不通过..."` | 拦截原因 | ### code 说明 | code | 说明 | 处理方式 | |------|------|----------| | **10000** | 成功 | 告知用户，返回 request_id | | **190001** | 风控拦截 | **不再重试**，告知用户风控原因 | | 其他 | 失败 | 根据 `result` 提示用户 | --- ## 📊 步骤 6：查询通话结果 外呼发起后，可查询通话结果： > 执行命令： > > `uv run scripts/query-call.py --request-id "{request_id}"` **查询参数说明**： | 参数 | 必填 | 说明 | |------|------|------| | --request-id | ✓ | 外呼返回的 request_id | | --no-retry | - | 禁用自动重试，只查询一次 | **重试机制**： | 配置 | 默认值 | 说明 | |------|--------|------| | 首次等待 | 30 秒 | 外呼完成后才能查询到结果 | | 重试间隔 | 20 秒 | 未完成时的重试间隔 | | 最大重试 | 12 次 | 总等待时间约 4.5 分钟 | --- ## 🚀 快速开始（首次使用引导） 当检测到用户首次使用（未配置 API Key）时，按以下话术引导： > 你好！我是你的智能通话助手，很高兴能为你服务。😊 > > 从现在起，那些琐碎的预约、重复的通知，或者偶尔不便亲自开口的询问，都可以放心地交给我。我会像真人助理一样，带着温度与对方沟通，并把最终结果整理成简报带回给你。 > > 但在我们开始第一次合作前，需要请你完成一个小小的「授权」： > > 🔑 请前往 [Outbound API 中心](https://www.skill.black) 申请您的专属 API Key，然后告诉我即可。 > > 配置完成后，你就可以随时吩咐我，例如： > 📞 "帮我给蜀九香打个电话，预订今晚 7 点 4 个人的位子" > 📞 "给老爸打个电话问候一下，确认他明天的体检时间" --- ## ⚙️ 配置 ### 前置要求 本 skill 需要以下工具： - `uv` - Python 运行器（ClawX 自带，跨平台） ### 环境变量（必需） | 变量名 | 必需 | 说明 | 默认值 | |--------|------|------|--------| | `OUTBOUND_API_KEY` | ✓ | API 密钥（申请地址见下方） | - | | `OUTBOUND_BASE_URL` | - | API 地址 | `https://www.skill.black` | ### 配置方式 **推荐：环境变量**（更安全） > 添加到 `~/.zshrc` 或 `~/.bashrc`： > > `export OUTBOUND_API_KEY="your-api-key"` > > **可选：自定义 API 地址** > > `export OUTBOUND_BASE_URL="https://www.skill.black"` **备选：配置文件** > 创建配置目录： > > `mkdir -p ~/.openclaw/secrets` > > 创建配置文件（注意设置权限）： > > `chmod 600 ~/.openclaw/secrets/outbound.json` --- ## 🔒 数据安全建议 ### 凭据安全 1. **优先使用环境变量**：避免将 API Key 存储在文件中 2. **文件权限**：如果使用配置文件，务必设置 `chmod 600` 3. **不要提交到代码仓库**：将 `outbound.json` 添加到 `.gitignore` ### 本地日志 以下文件包含 PII（电话号码、对话内容）： - `memory/skills/requests.jsonl` - 外呼请求记录 - `memory/skills/costs.jsonl` - 通话日志 **用户可随时删除这些文件以清除历史记录。**

path: _meta.json
content: ownerId: kn7dmjqt98tcjtgbpahzarpqdh82mbjd
slug: ai-phone-call
version: 1.0.1
publishedAt: 1776180029678

path: references/api-reference.md
content: # Outbound AI Call API Reference

## 认证

所有请求需要 Header：
```
Content-Type: application/json
X-Access-Key: {API_KEY}
```

## 创建外呼

**Endpoint:** `POST /api/voice_call/llmSceneCall`
**Base URL:** `https://www.skill.black`

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| phone | string | 是 | 目标电话号码 |
| messages | string[] | 是 | 对话上下文数组，格式：`["用户: ...", "助手: ..."]` |

**请求示例：**

```json
{
  "phone": "18033009923",
  "messages": [
    "用户: 帮我给蜀九香打个电话预约位子",
    "助手: 好的，请问预约什么时间？几位用餐？",
    "用户: 今天晚上7点，4个人"
  ]
}
```

**响应：**

**成功响应**：
```json
{
  "code": "10000",
  "result": "成功",
  "data": "12331@e561a4397b2a402488f2-638cd593f315"
}
```

**失败响应**：
```json
{
  "code": "190001",
  "result": "风控校验不通过不能外呼,风控拦截信息：1001涉政"
}
```

**code 说明：**

| code | 说明 | 处理方式 |
|------|------|----------|
| **10000** | 成功 | 使用 data 中的 request_id 查询结果 |
| **190001** | 风控拦截 | **不再重试**，告知用户风控原因 |
| 其他 | 失败 | 根据 result 判断原因 |

**常见错误码：**

| 错误码 | 说明 | 解决方案 |
|--------|------|----------|
| 130003 | 调用业务系统异常 | 稍后重试 |
| 190001 | 风控拦截 | 更换通话内容或联系管理员 |

## 查询通话记录

**Endpoint:** `POST /api/voice_call/llmSceneCallResult`
**Base URL:** `https://www.skill.black`

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| reqId | string | 是 | 创建外呼返回的 request_id |

**请求示例：**

```json
{
  "reqId": "2417278@uZGMSV5JDKEMJp67wOd93Q=="
}
```

**响应：**

**成功响应**：
```json
{
  "code": "10000",
  "result": "成功",
  "data": {
    "phone": "18033009923",
    "status": "5",
    "statusDesc": "已接通",
    "category": "CONNECTED",
    "callStartTime": "2026-03-20 15:51:17",
    "chatLogs": [
      {"role": "BOT", "content": "您好...", "msgTime": "2026-03-20 15:51:23"},
      {"role": "USER", "content": "你好", "msgTime": "2026-03-20 15:51:31"}
    ],
    "sid": "900581c1a8904c55b298d1e8ee53fd5d@1_1484459148920758272_2417278"
  }
}
```

**失败响应**：
```json
{
  "code": "140004",
  "result": "查询记录不存在"
}
```

**code 说明：**

| code | 说明 | 处理方式 |
|------|------|----------|
| **10000** | 成功 | 解析 data 中的通话信息 |
| 其他 | 失败 | 根据 result 判断原因 |
```

**通话状态码说明（data.status）：**

| status | statusDesc | 说明 |
|--------|------------|------|
| 1 | 待呼叫 | 排队中 |
| 13 | 呼叫中 | 正在拨打电话 |
| **5** | 已接通 | 对话完成，无需再查询 |
| 8 | 占线/无人接听 | 呼叫失败 |

**category 说明：**

| category | 说明 |
|----------|------|
| WAITING | 待呼叫 |
| CALLING | 呼叫中 |
| CONNECTED | 已接通 - 对话完成 |
| UN_CONNECTED | 未接通 |

## 配置

API Key 存放位置（二选一）：

1. 环境变量：`OUTBOUND_API_KEY`
2. 配置文件：`~/.openclaw/secrets/outbound.json`

```json
{
  "api_key": "your-api-key",
  "base_url": "https://www.skill.black"
}
```

## 错误码

| 错误码 | 说明 | 解决方案 |
|--------|------|----------|
| 401 | API Key 无效 | 检查配置 |
| 429 | 频率限制 | 降低请求频率 |
| 500 | 服务器错误 | 稍后重试 |

path: scripts/make-call.py
content: #!/usr/bin/env python3
"""
Outbound AI Call - Create Outbound Call
Cross-platform Python version (works on Windows, macOS, Linux)
"""

import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError


def get_config():
    """Load API key and base URL from environment or config file."""
    [REDACTED_SECRET]"OUTBOUND_API_KEY", "")
    base_url = os.environ.get("OUTBOUND_BASE_URL", "https://www.skill.black")
    
    # Try config file if env not set
    if not [REDACTED_SECRET] = Path.home() / ".openclaw" / "secrets" / "outbound.json"
        if config_path.exists():
            try:
                config = json.loads(config_path.read_text())
                [REDACTED_SECRET]"api_key", "")
                base_url = config.get("base_url", base_url)
            except (json.JSONDecodeError, IOError):
                pass
    
    return api_key, base_url


def validate_phone(phone: str) -> bool:
    """Validate Chinese mobile phone number format."""
    return bool(re.match(r"^1[3-9]\d{9}$", phone))


def make_call(phone: str, messages: list, must_outbound: bool = False, debug: bool = False):
    """Execute outbound call API request."""
    api_key, base_url = get_config()
    
    if not [REDACTED_SECRET]"Error: OUTBOUND_API_KEY not set")
        print("Run: export OUTBOUND_API_KEY=your-api-key")
        print("Or create ~/.openclaw/secrets/outbound.json")
        sys.exit(1)
    
    # Build request
    url = f"{base_url}/api/voice_call/llmSceneCall"
    request_body = {
        "phone": phone,
        "messages": messages,
        "mustOutbound": must_outbound
    }
    
    if debug:
        print("=== Request Info ===")
        print(f"URL: {url}")
        print(f"Body: {json.dumps(request_body, ensure_ascii=False)}")
        print()
    
    # Send request
    req = Request(
        url,
        data=json.dumps(request_body).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "X-Access-Key": api_key
        },
        method="POST"
    )
    
    try:
        with urlopen(req, timeout=30) as response:
            body = response.read().decode("utf-8")
            result = json.loads(body)
    except HTTPError as e:
        body = e.read().decode("utf-8")
        print(f"HTTP Error: {e.code}")
        print(body)
        sys.exit(1)
    except URLError as e:
        print(f"Network Error: {e.reason}")
        sys.exit(1)
    
    # Parse response - API returns: {"code": "10000", "message": "成功", "data": xxx}
    code = result.get("code", "")
    message = result.get("message", "")
    data = result.get("data", "")
    
    # Handle response
    if code == "10000":
        print("✓ Call created successfully")
        print()
        print(f"Request ID: {data}")
        print(f"Message: {message}")
        
        # Save to requests.jsonl
        skill_dir = Path(__file__).parent.parent
        memory_dir = skill_dir / "memory" / "skills"
        memory_dir.mkdir(parents=True, exist_ok=True)
        requests_file = memory_dir / "requests.jsonl"
        
        record = {
            "request_id": data,
            "phone": phone,
            "status": "pending",
            "messages": messages,
            "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        }
        
        with open(requests_file, "a", encoding="utf-8") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")
        
        print()
        print(f"Request tracked in: {requests_file}")
        print()
        print("To query call result:")
        print(f'  uv run scripts/query-call.py --request-id "{data}"')
        
        return data
    
    elif code == "190001":
        print("✗ Call blocked by risk control")
        print(f"Code: {code}")
        print(f"Reason: {message}")
        print()
        print("风控拦截，无法外呼。请更换通话内容或联系管理员。")
        sys.exit(2)
    
    elif code == "110000":
        print("✗ Authentication failed")
        print(f"Code: {code}")
        print(f"Message: {message or result_msg}")
        print()
        print("API Key 无效或已过期，请更新配置：")
        print("  1. 申请 Key: https://www.skill.black")
        print("  2. 更新环境变量: export OUTBOUND_API_KEY=your-new-key")
        print("  或更新配置文件: ~/.openclaw/secrets/outbound.json")
        sys.exit(1)
    
    elif code == "430004":
        print("✗ Missing required parameters")
        print(f"Code: {code}")
        print(f"Message: {message}")
        print()
        print("后端判断缺少必填参数，请补充信息后重试。")
        print("或使用 --must-outbound 强制外呼。")
        sys.exit(1)
    
    elif code == "430005":
        print("✗ Missing optional parameters")
        print(f"Code: {code}")
        print(f"Message: {message}")
        print()
        print("后端建议补充更多信息，可以使用 --must-outbound 强制外呼。")
        sys.exit(1)
    
    else:
        print("✗ Call creation failed")
        print(f"Code: {code}")
        print(f"Message: {message}")
        sys.exit(1)


def main():
    parser = argparse.ArgumentParser(
        description="Create a Outbound AI outbound call",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Basic call
  uv run scripts/make-call.py --phone "18033009923" --messages '["用户: 帮我给蜀九香预约位子"]'

  # Force outbound (even with incomplete info)
  uv run scripts/make-call.py --phone "15200001111" --messages '["用户: 给我爸打电话"]' --must-outbound
"""
    )
    
    parser.add_argument("--phone", required=True, help="Target phone number (e.g., 13800138000)")
    parser.add_argument("--messages", required=True, help="Conversation messages as JSON array")
    parser.add_argument("--must-outbound", action="store_true", help="Force outbound even with incomplete info")
    parser.add_argument("--debug", action="store_true", help="Enable debug output")
    
    args = parser.parse_args()
    
    # Validate phone
    if not validate_phone(args.phone):
        print(f"Error: Invalid phone number format (expected: 1xxxxxxxxxx, got: {args.phone})")
        sys.exit(1)
    
    # Parse messages
    try:
        messages = json.loads(args.messages)
        if not isinstance(messages, list):
            raise ValueError("Messages must be a JSON array")
    except json.JSONDecodeError as e:
        print(f"Error: Invalid JSON for messages: {e}")
        sys.exit(1)
    
    make_call(args.phone, messages, args.must_outbound, args.debug)


if __name__ == "__main__":
    main()

path: scripts/query-call.py
content: #!/usr/bin/env python3
"""
Outbound AI Call - Query Call Records
Cross-platform Python version (works on Windows, macOS, Linux)

Supports automatic retry mechanism and request state tracking.
"""

import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError


# Retry configuration
INITIAL_WAIT = 30      # First query wait time (seconds)
RETRY_INTERVAL = 20    # Retry interval (seconds)
MAX_RETRIES = 12       # Maximum retry attempts (total ~4.5 min)


def get_config():
    """Load API key and base URL from environment or config file."""
    [REDACTED_SECRET]"OUTBOUND_API_KEY", "")
    base_url = os.environ.get("OUTBOUND_BASE_URL", "https://www.skill.black")
    
    if not [REDACTED_SECRET] = Path.home() / ".openclaw" / "secrets" / "outbound.json"
        if config_path.exists():
            try:
                config = json.loads(config_path.read_text())
                [REDACTED_SECRET]"api_key", "")
                base_url = config.get("base_url", base_url)
            except (json.JSONDecodeError, IOError):
                pass
    
    return api_key, base_url


def get_memory_paths():
    """Get paths to memory files."""
    skill_dir = Path(__file__).parent.parent
    memory_dir = skill_dir / "memory" / "skills"
    memory_dir.mkdir(parents=True, exist_ok=True)
    return (
        memory_dir / "requests.jsonl",
        memory_dir / "costs.jsonl"
    )


def load_jsonl(path: Path) -> list:
    """Load JSONL file as list of records.
    
    Handles various edge cases:
    - File doesn't exist
    - Invalid JSON lines (skipped)
    - Non-dict values (skipped)
    - Multi-line JSON (attempts to fix)
    """
    if not path.exists():
        return []
    
    records = []
    with open(path, "r", encoding="utf-8") as f:
        content = f.read()
    
    # 尝试按行解析
    for line in content.split("\n"):
        line = line.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
            # 只接受字典类型
            if isinstance(obj, dict):
                records.append(obj)
        except json.JSONDecodeError:
            # 跳过无效行
            pass
    
    return records


def save_jsonl(path: Path, records: list):
    """Save list of records to JSONL file."""
    with open(path, "w", encoding="utf-8") as f:
        for record in records:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")


def get_request_state(requests_file: Path, request_id: str) -> dict:
    """Get request state from requests.jsonl."""
    records = load_jsonl(requests_file)
    for record in reversed(records):  # Most recent first
        if isinstance(record, dict) and record.get("request_id") == request_id:
            return record
    return {}


def update_request_state(requests_file: Path, request_id: str, status: str, retry_count: int = 0):
    """Update or create request state in requests.jsonl."""
    records = load_jsonl(requests_file)
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    
    # Find and update existing record
    found = False
    for record in records:
        if isinstance(record, dict) and record.get("request_id") == request_id:
            record["status"] = status
            record["retry_count"] = retry_count
            record["updated_at"] = timestamp
            found = True
            break
    
    # Create new if not found
    if not found:
        records.append({
            "request_id": request_id,
            "status": status,
            "retry_count": retry_count,
            "created_at": timestamp,
            "updated_at": timestamp
        })
    
    # Deduplicate by request_id (keep most recent)
    seen = set()
    unique_records = []
    for record in reversed(records):
        rid = record.get("request_id")
        if rid not in seen:
            seen.add(rid)
            unique_records.append(record)
    
    save_jsonl(requests_file, list(reversed(unique_records)))


def save_call_record(costs_file: Path, request_id: str, call_data: dict):
    """Save call record to costs.jsonl."""
    records = load_jsonl(costs_file)
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    
    # Build record
    record = {
        "request_id": request_id,
        "phone": call_data.get("phone", ""),
        "status": call_data.get("status", ""),
        "statusDesc": call_data.get("statusDesc", ""),
        "category": call_data.get("category", ""),
        "callStartTime": call_data.get("callStartTime", ""),
        "sid": call_data.get("sid", ""),
        "chatLogs": call_data.get("chatLogs", []),
        "timestamp": timestamp
    }
    
    # Update or append
    found = False
    for i, r in enumerate(records):
        if r.get("request_id") == request_id:
            records[i] = record
            found = True
            break
    
    if not found:
        records.append(record)
    
    save_jsonl(costs_file, records)


def query_api(request_id: str, debug: bool = False) -> dict:
    """Query API for call record."""
    api_key, base_url = get_config()
    
    if not [REDACTED_SECRET]"Error: OUTBOUND_API_KEY not set")
        sys.exit(1)
    
    url = f"{base_url}/api/voice_call/llmSceneCallResult"
    request_body = {"reqId": request_id}
    
    if debug:
        print("=== API Request ===")
        print(f"URL: {url}")
        print(f"Body: {json.dumps(request_body)}")
        print()
    
    req = Request(
        url,
        data=json.dumps(request_body).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "X-Access-Key": api_key
        },
        method="POST"
    )
    
    try:
        with urlopen(req, timeout=30) as response:
            body = response.read().decode("utf-8")
            return json.loads(body)
    except HTTPError as e:
        print(f"HTTP Error: {e.code}")
        body = e.read().decode("utf-8")
        print(body)
        return None
    except URLError as e:
        print(f"Network Error: {e.reason}")
        return None


def parse_call_response(response: dict) -> dict:
    """Parse API response and extract call data.
    
    API returns: {"code": "10000", "message": "成功", "data": {...}}
    """
    if not response:
        return None
    
    code = response.get("code", "")
    message = response.get("message", "")
    data = response.get("data", {}) or {}
    
    return {
        "code": code,
        "message": message,
        "phone": data.get("phone", ""),
        "status": str(data.get("status", "0")),
        "statusDesc": data.get("statusDesc", ""),
        "category": data.get("category", "UNKNOWN"),
        "callStartTime": data.get("callStartTime", ""),
        "sid": data.get("sid", ""),
        "chatLogs": data.get("chatLogs", [])
    }


def print_call_result(call_data: dict, request_id: str, costs_file: Path):
    """Print call result and save to costs file."""
    print("=== Call Record Retrieved ===")
    print()
    print(f"Phone: {call_data['phone']}")
    print(f"Status: {call_data['statusDesc']} (status: {call_data['status']}, category: {call_data['category']})")
    print(f"Call Start: {call_data['callStartTime']}")
    print(f"Session ID: {call_data['sid']}")
    print()
    print("=== Conversation ===")
    
    chat_logs = call_data.get("chatLogs", [])
    if chat_logs:
        for log in chat_logs:
            msg_time = log.get("msgTime", "")
            role = log.get("role", "")
            content = log.get("content", "")
            print(f"[{msg_time}] {role}: {content}")
    else:
        print("(No conversation logs)")
    
    print()
    
    # Save record
    save_call_record(costs_file, request_id, call_data)
    print(f"Record saved to: {costs_file}")


def query_by_request_id(request_id: str, no_retry: bool = False, debug: bool = False):
    """Query by request ID with optional retry mechanism."""
    requests_file, costs_file = get_memory_paths()
    
    print(f"=== Querying Request: {request_id} ===")
    
    # Check existing state
    existing_state = get_request_state(requests_file, request_id)
    retry_count = existing_state.get("retry_count", 0)
    
    if existing_state.get("status") == "completed":
        print("Request already completed. Checking local logs...")
        records = load_jsonl(costs_file)
        for record in records:
            if record.get("request_id") == request_id:
                print(json.dumps(record, indent=2, ensure_ascii=False))
                return
        print("No local record found.")
        return
    
    if retry_count >= MAX_RETRIES:
        print(f"Max retries ({MAX_RETRIES}) exceeded for this request.")
        print("Status: failed")
        update_request_state(requests_file, request_id, "failed", retry_count)
        sys.exit(1)
    
    # No retry mode
    if no_retry:
        update_request_state(requests_file, request_id, "querying", retry_count)
        response = query_api(request_id, debug)
        
        if not response:
            update_request_state(requests_file, request_id, "pending", retry_count + 1)
            sys.exit(1)
        
        call_data = parse_call_response(response)
        
        if call_data["code"] != "10000":
            print(f"API Error: {call_data['code']} - {call_data['message']}")
            update_request_state(requests_file, request_id, "pending", retry_count + 1)
            sys.exit(1)
        
        # Check call status
        if call_data["status"] == "5" or call_data["category"] == "CONNECTED":
            print_call_result(call_data, request_id, costs_file)
            update_request_state(requests_file, request_id, "completed", retry_count)
        elif call_data["status"] == "8" or call_data["category"] == "UN_CONNECTED":
            print("=== Call Failed ===")
            print(f"Phone: {call_data['phone']}")
            print(f"Status: {call_data['statusDesc']} (status: {call_data['status']})")
            update_request_state(requests_file, request_id, "completed", retry_count)
        else:
            print(f"Call status: {call_data['statusDesc']} (status: {call_data['status']}, category: {call_data['category']})")
            print("Call not completed yet.")
            update_request_state(requests_file, request_id, "pending", retry_count + 1)
        
        return
    
    # With retry mechanism
    while retry_count < MAX_RETRIES:
        if retry_count == 0:
            print(f"First query attempt (waiting {INITIAL_WAIT}s)...")
            time.sleep(INITIAL_WAIT)
        else:
            print(f"Retry attempt {retry_count} (waiting {RETRY_INTERVAL}s)...")
            time.sleep(RETRY_INTERVAL)
        
        update_request_state(requests_file, request_id, "querying", retry_count)
        
        response = query_api(request_id, debug)
        
        if not response:
            print("Query failed")
            retry_count += 1
            update_request_state(requests_file, request_id, "pending", retry_count)
            continue
        
        call_data = parse_call_response(response)
        
        if call_data["code"] != "10000":
            print(f"API Error: {call_data['code']} - {call_data['message']}")
            print("Call not completed yet. Waiting...")
            retry_count += 1
            update_request_state(requests_file, request_id, "pending", retry_count)
            continue
        
        # Check call status
        if call_data["status"] == "5" or call_data["category"] == "CONNECTED":
            print_call_result(call_data, request_id, costs_file)
            update_request_state(requests_file, request_id, "completed", retry_count)
            print()
            print("Note: status=5 means conversation completed, no further polling needed.")
            return
        
        elif call_data["status"] == "8" or call_data["category"] == "UN_CONNECTED":
            print("=== Call Failed ===")
            print(f"Phone: {call_data['phone']}")
            print(f"Status: {call_data['statusDesc']} (status: {call_data['status']})")
            print("No retry needed for failed calls.")
            save_call_record(costs_file, request_id, call_data)
            update_request_state(requests_file, request_id, "completed", retry_count)
            return
        
        else:
            print(f"Call status: {call_data['statusDesc']} (status: {call_data['status']}, category: {call_data['category']})")
            print("Call not completed yet. Waiting...")
            retry_count += 1
            update_request_state(requests_file, request_id, "pending", retry_count)
    
    # Max retries exceeded
    print()
    print(f"Max retries ({MAX_RETRIES}) exceeded.")
    print("Performing final query to save current state...")
    
    final_response = query_api(request_id, debug)
    if final_response:
        call_data = parse_call_response(final_response)
        print(f"Final status: {call_data['statusDesc']} (status: {call_data['status']}, category: {call_data['category']})")
        save_call_record(costs_file, request_id, call_data)
        print(f"Record saved to: {costs_file}")
    
    update_request_state(requests_file, request_id, "failed", retry_count)
    sys.exit(1)


def query_local(status: str = None, phone: str = None, limit: int = 20):
    """Query local call records."""
    _, costs_file = get_memory_paths()
    
    if not costs_file.exists():
        print("No local call records found")
        print(f"Log file: {costs_file}")
        return
    
    print("=== Local Call Records ===")
    print(f"Log file: {costs_file}")
    print()
    
    records = load_jsonl(costs_file)
    
    # Filter
    if status:
        records = [r for r in records if r.get("status") == status]
    if phone:
        records = [r for r in records if r.get("phone") == phone]
    
    # Limit
    records = records[:limit]
    
    for record in records:
        print(json.dumps(record, ensure_ascii=False))
    
    print()
    print(f"Total records: {len(records)}")


def main():
    parser = argparse.ArgumentParser(
        description="Query Outbound AI call records with automatic retry mechanism",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Retry Mechanism:
  - First query after 30 seconds (required for call to complete)
  - Retry every 20 seconds if call not completed
  - Maximum 12 retries (total ~4.5 min)
  - Completed requests won't be re-queried

Examples:
  # Query by request ID (with retry)
  uv run scripts/query-call.py --request-id "e561a439-7b2a-4024-88f2-638cd593f315"

  # Query without retry
  uv run scripts/query-call.py --request-id "e561a439-7b2a-4024-88f2-638cd593f315" --no-retry

  # Query local logs
  uv run scripts/query-call.py --local --status 5 --limit 10
"""
    )
    
    parser.add_argument("--request-id", help="Request ID (returned from make-call)")
    parser.add_argument("--no-retry", action="store_true", help="Disable automatic retry")
    parser.add_argument("--debug", action="store_true", help="Enable debug output")
    parser.add_argument("--local", action="store_true", help="Query local logs only")
    parser.add_argument("--status", help="Filter by status (for local query)")
    parser.add_argument("--phone", help="Filter by phone (for local query)")
    parser.add_argument("--limit", type=int, default=20, help="Result limit (default: 20)")
    
    args = parser.parse_args()
    
    if args.local:
        query_local(args.status, args.phone, args.limit)
    elif args.request_id:
        query_by_request_id(args.request_id, args.no_retry, args.debug)
    else:
        print("Please specify --request-id or --local")
        print("Run: uv run scripts/query-call.py --help")
        sys.exit(1)


if __name__ == "__main__":
    main()

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