Skill: volcengine-skills/byted-voice-notify (version 1.0.0)

--- name: voice-notify description: 火山引擎语音通知发送。使用火山引擎语音服务API向指定手机号码发送语音通知。当用户需要发送语音通知时使用本技能。 license: Complete terms in LICENSE.txt metadata: {"openclaw":{ "emoji":"📞 ", "requires":{"env":["VOLCENGINE_ACCESS_KEY","VOLCENGINE_SECRET_KEY"]}, "os":["darwin","linux"], "triggers": ["发送语音通知", "打语音电话", "语音提醒", "给.*发送语音", "语音通知"] }} --- # 火山引擎语音通知发送技能 基于火山引擎语音服务API向指定手机号码发送语音通知。 ## 适用场景 1. 用户提到「给{手机号}发送语音通知」「帮我给{手机号}打语音电话」 2. 用户提到「用{语言}给{手机号}发送语音通知」 3. 用户提到「给{手机号}发送{关键词}语音通知」 4. 用户需要通过语音方式通知重要信息 ## 核心执行流程 **当你收到发送语音通知的请求时，请严格按照以下 4 个步骤依次执行：** --- ### 第 1 步：提取手机号码 从用户的指令中提取 11 位手机号码。如果用户没有提供手机号，请先询问用户。 --- ### 第 2 步：查询可用号码池 执行以下命令查询号码池： ```bash python3 scripts/voice_notify.py pool ``` 从返回的 JSON 结果中： - 查看 `Result.Records` 数组 - 选择一个 `NumberCount > 0`（有号码可用）的号码池 - 提取该号码池的 `NumberPoolNo` 字段值（例如：`NP165357678402826428`） **注意：** 优先选择第一个有号码的号码池。 --- ### 第 3 步：查询语音资源 执行以下命令查询语音资源： ```bash python3 scripts/voice_notify.py resource ``` 从返回的 JSON 结果中： - 查看 `Result.Records` 数组 - 根据用户指令中的关键词，在资源的 `Name`、`Remark`、`TtsTemplateContent`、`Lang` 字 段中匹配 - 选择最匹配的语音资源 - 提取该资源的 `ResourceKey` 字段值 **资源选择规则：** 1. 如果没有匹配的关键词，选择列表中的第一个资源 --- ### 第 4 步：发送语音通知 执行以下命令发送语音通知： ```bash python3 scripts/voice_notify.py send <手机号> <资源Key> <号码池编号> ``` 将第 1 步提取的手机号、第 3 步提取的资源Key、第 2 步提取的号码池编号填入命令中。 --- ### 第 5 步：返回结果 将 `send` 命令的返回结果直接告知用户。 --- ## 脚本命令说明 | 命令 | 参数 | 说明 | | ---------- | --------------------------------- | ---------------------- | | `resource` | 无 | 查询可用语音资源列表 | | `pool` | 无 | 查询语音通知号码池列表 | | `send` | `<手机号> <资源Key> <号码池编号>` | 发送语音通知 | ## 完整使用示例 ### 示例 1：简单发送 **用户输入：** 给13800138000发语音通知 **你的执行：** 1. 提取手机号：`13800138000` 2. 查询号码池： ```bash python3 scripts/voice_notify.py pool ``` 假设返回结果中第一个有号码的号码池的 `NumberPoolNo` 是 `NPabc` 3. 查询语音资源： ```bash python3 scripts/voice_notify.py resource ``` 假设返回结果中第一个资源的 `ResourceKey` 是 [REDACTED_SECRET] 4. 发送语音通知： ```bash python3 scripts/voice_notify.py send 13800138000 312346368e676406285b8463931f090f4 NPabc ``` 5. 将发送结果告知用户 --- ### 示例 2：指定语言 **用户输入：** 用日语给13800138000发送语音通知 **你的执行：** 1. 提取手机号：`13800138000` 2. 查询号码池： ```bash python3 scripts/voice_notify.py pool ``` 选择第一个有号码的号码池 3. 查询语音资源： ```bash python3 scripts/voice_notify.py resource ``` 在返回的资源列表中，查找 `Lang` 为 `jap` 或 `Name`/`Remark` 包含"日语"的资源，提取其 `ResourceKey` 4. 发送语音通知： ```bash python3 scripts/voice_notify.py send 13800138000 <日语资源Key> <号码池编号> ``` 5. 将发送结果告知用户 --- ### 示例 3：指定关键词 **用户输入：** 给13800138000发送OCIC语音通知 **你的执行：** 1. 提取手机号：`13800138000` 2. 查询号码池： ```bash python3 scripts/voice_notify.py pool ``` 选择第一个有号码的号码池 3. 查询语音资源： ```bash python3 scripts/voice_notify.py resource ``` 在返回的资源列表中，查找 `Name` 或 `Remark` 包含"OCIC"的资源，提取其 `ResourceKey` 4. 发送语音通知： ```bash python3 scripts/voice_notify.py send 13800138000 <OCIC资源Key> <号码池编号> ``` 5. 将发送结果告知用户 --- ## 环境变量 使用前需配置火山引擎访问密钥到系统环境变量或 OpenClaw 的 .env 文件： - **VOLCENGINE_ACCESS_KEY**：火山引擎访问密钥ID - **VOLCENGINE_SECRET_KEY**：火山引擎秘密访问密钥 获取方式：登录火山引擎控制台，在访问密钥管理中创建和获取。 ## 错误处理 - 若报错 `请设置环境变量 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY`：提示用户配置火山引擎访问密钥。 - 若报错 `没有找到可用的号码池`：提示用户检查账户下是否有可用的语音通知号码池。 - 若报错 `没有找到可用的语音资源`：提示用户检查账户下是否有可用的语音资源文件。 - 遇到其他报错时直接告知用户具体错误信息。 ## 参考文档 - `https://www.volcengine.com/docs/6358/166389?lang=zh` — 签名机制 - `https://www.volcengine.com/docs/6358/1722078?lang=zh` — 查询可用语音资源 - `https://www.volcengine.com/docs/6358/172952?lang=zh` — 单次发送语音通知 - `https://www.volcengine.com/docs/6358/173339?lang=zh` — 查询号码池列表

path: scripts/voice_notify.py
content: # Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

#!/usr/bin/env python3
import datetime
import json
import hashlib
import hmac
import uuid
import sys
import os
from typing import Any, Dict, Optional, List
from urllib import request, error, parse


class VmsError(Exception):
    """火山引擎语音服务异常类"""
    pass


DEFAULT_HOST = "cloud-vms.volcengineapi.com"
DEFAULT_VERSION = "2022-01-01"
DEFAULT_REGION = "cn-beijing"
DEFAULT_SERVICE = "vms"


def _utc_xdate(dt: Optional[datetime.datetime] = None) -> str:
    if dt is None:
        dt = datetime.datetime.utcnow()
    return dt.strftime("%Y%m%dT%H%M%SZ")


def _sha256_hex(content: bytes) -> str:
    return hashlib.sha256(content).hexdigest()


def _hmac_sha256(key: bytes, content: str) -> bytes:
    return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest()


def _norm_uri(path: str) -> str:
    return parse.quote(path).replace("%2F", "/").replace("+", "%20")


def _norm_query(params: Dict[str, Any]) -> str:
    items = []
    for k in sorted(params.keys()):
        v = str(params[k])
        items.append(
            parse.quote(k, safe="-_.~") + "=" + parse.quote(v, safe="-_.~")
        )
    return "&".join(items).replace("+", "%20")


def _signing_key(sk: str, date: str, region: str, service: str) -> bytes:
    kdate = _hmac_sha256(sk.encode("utf-8"), date)
    kregion = _hmac_sha256(kdate, region)
    kservice = _hmac_sha256(kregion, service)
    return _hmac_sha256(kservice, "request")


def _build_canonical_request(
    method: str,
    path: str,
    query: Dict[str, Any],
    headers: Dict[str, str],
    body: bytes,
):
    body_hash = _sha256_hex(body)
    signed_headers = {}
    for k, v in headers.items():
        if k in ("Content-Type", "Content-Md5", "Host") or k.startswith("X-"):
            signed_headers[k.lower()] = v.strip()
    if "host" in signed_headers:
        v = signed_headers["host"]
        if ":" in v:
            host, port = v.split(":", 1)
            if port in ("80", "443"):
                signed_headers["host"] = host
    signed_headers_str = ""
    for k in sorted(signed_headers.keys()):
        signed_headers_str += f"{k}:{signed_headers[k]}\n"
    signed_headers_keys = ";".join(sorted(signed_headers.keys()))
    canonical_request = "\n".join(
        [
            method,
            _norm_uri(path or "/"),
            _norm_query(query),
            signed_headers_str,
            signed_headers_keys,
            body_hash,
        ]
    )
    return (
        hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
        signed_headers_keys,
        body_hash,
    )


def _build_authorization(
    ak: str,
    sk: str,
    region: str,
    service: str,
    xdate: str,
    method: str,
    path: str,
    query: Dict[str, Any],
    headers: Dict[str, str],
    body: bytes,
) -> (str, str):
    date = xdate[:8]
    hashed_req, signed_headers, body_hash = _build_canonical_request(
        method, path, query, headers, body
    )
    credential_scope = "/".join([date, region, service, "request"])
    signing_str = "\n".join(["HMAC-SHA256", xdate, credential_scope, hashed_req])
    key = _signing_key(sk, date, region, service)
    signature = hmac.new(key, signing_str.encode("utf-8"), hashlib.sha256).hexdigest()
    auth = f"HMAC-SHA256 Credential={ak}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
    return auth, body_hash


def _signed_post(
    ak: str,
    sk: str,
    host: str,
    action: str,
    version: str,
    body_obj: Dict[str, Any],
    *,
    region: str = DEFAULT_REGION,
    service: str = DEFAULT_SERVICE,
    timeout: int = 10,
    scheme: str = "https",
    path: str = "/",
) -> Dict[str, Any]:
    query = {"Action": action, "Version": version}
    url = f"{scheme}://{host}{path}?{_norm_query(query)}"
    body = json.dumps(body_obj, ensure_ascii=False, separators=(",", ":")).encode(
        "utf-8"
    )
    headers = {"Content-Type": "application/json", "Host": host}
    xdate = _utc_xdate()
    headers["X-Date"] = xdate
    auth, body_hash = _build_authorization(
        ak, sk, region, service, xdate, "POST", path, query, headers, body
    )
    headers["Authorization"] = auth
    req = request.Request(url=url, data=body, headers=headers, method="POST")
    try:
        with request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
            return json.loads(raw) if raw else {}
    except error.HTTPError as e:
        detail = (
            e.read().decode("utf-8", errors="ignore") if hasattr(e, "read") else ""
        )
        raise VmsError(f"HTTP {e.code}: {detail}") from e
    except error.URLError as e:
        raise VmsError(str(e)) from e
    except json.JSONDecodeError as e:
        raise VmsError("Invalid JSON response") from e


def _signed_post_form(
    ak: str,
    sk: str,
    host: str,
    action: str,
    version: str,
    form_data: Dict[str, Any],
    *,
    region: str = DEFAULT_REGION,
    service: str = DEFAULT_SERVICE,
    timeout: int = 10,
    scheme: str = "https",
    path: str = "/",
) -> Dict[str, Any]:
    query = {"Action": action, "Version": version}
    url = f"{scheme}://{host}{path}?{_norm_query(query)}"
    form_items = []
    for k, v in form_data.items():
        form_items.append(f"{parse.quote(str(k), safe='')}={parse.quote(str(v), safe='')}")
    body = "&".join(form_items).encode("utf-8")
    headers = {"Content-Type": "application/x-www-form-urlencoded", "Host": host}
    xdate = _utc_xdate()
    headers["X-Date"] = xdate
    auth, body_hash = _build_authorization(
        ak, sk, region, service, xdate, "POST", path, query, headers, body
    )
    headers["Authorization"] = auth
    req = request.Request(url=url, data=body, headers=headers, method="POST")
    try:
        with request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
            return json.loads(raw) if raw else {}
    except error.HTTPError as e:
        detail = (
            e.read().decode("utf-8", errors="ignore") if hasattr(e, "read") else ""
        )
        raise VmsError(f"HTTP {e.code}: {detail}") from e
    except error.URLError as e:
        raise VmsError(str(e)) from e
    except json.JSONDecodeError as e:
        raise VmsError("Invalid JSON response") from e


def query_voice_resources(
    ak: str,
    sk: str,
    *,
    host: str = DEFAULT_HOST,
    version: str = DEFAULT_VERSION,
    region: str = DEFAULT_REGION,
    timeout: int = 10,
    scheme: str = "https",
    path: str = "/",
) -> Dict[str, Any]:
    query_params = {"Type": 0, "Limit": 10, "Offset": 0}
    return _signed_post(
        ak, sk, host, "GetVoiceResourceList", version, query_params,
        region=region, timeout=timeout, scheme=scheme, path=path
    )


def query_number_pools(
    ak: str,
    sk: str,
    *,
    host: str = DEFAULT_HOST,
    version: str = DEFAULT_VERSION,
    region: str = DEFAULT_REGION,
    timeout: int = 10,
    scheme: str = "https",
    path: str = "/",
) -> Dict[str, Any]:
    form_data = {
        "SubServiceType": 102,
        "Limit": 5,
        "Offset": 0
    }
    return _signed_post_form(
        ak, sk, host, "NumberPoolList", version, form_data,
        region=region, timeout=timeout, scheme=scheme, path=path
    )


def create_voice_notify(
    ak: str,
    sk: str,
    phone_number: str,
    resource: str,
    number_pool_no: str,
    *,
    type: int = 0,
    single_open_id: Optional[str] = None,
    host: str = DEFAULT_HOST,
    version: str = DEFAULT_VERSION,
    region: str = DEFAULT_REGION,
    timeout: int = 10,
    scheme: str = "https",
    path: str = "/",
) -> Dict[str, Any]:
    if single_open_id is None:
        single_open_id = uuid.uuid4().hex
    
    single_param: Dict[str, Any] = {
        "Phone": phone_number,
        "Resource": resource,
        "NumberPoolNo": number_pool_no,
        "Type": type,
        "SingleOpenId": single_open_id
    }
    
    body_obj = {"List": [single_param]}
    return _signed_post(
        ak, sk, host, "SingleBatchAppend", version, body_obj,
        region=region, timeout=timeout, scheme=scheme, path=path
    )


def get_ak_sk_from_env() -> tuple[str, str]:
    ak = os.getenv('VOLCENGINE_ACCESS_KEY')
    sk = os.getenv('VOLCENGINE_SECRET_KEY')

    
    if not ak or not sk:
        raise VmsError("请设置环境变量 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY")
    
    return ak, sk


if __name__ == "__main__":
    try:
        ak, sk = get_ak_sk_from_env()
    except VmsError as e:
        print(f"[voice-notify ERROR] {e}", file=sys.stderr)
        sys.exit(1)
    
    if len(sys.argv) < 2:
        print("[voice-notify ERROR] 请指定命令", file=sys.stderr)
        print("用法:", file=sys.stderr)
        print("  python scripts/voice_notify.py resource                     # 查询语音资源", file=sys.stderr)
        print("  python scripts/voice_notify.py pool                        # 查询号码池", file=sys.stderr)
        print("  python scripts/voice_notify.py send <手机号> <资源Key> <号码池编号>  # 发送语音通知", file=sys.stderr)
        sys.exit(1)
    
    command = sys.argv[1]
    
    try:
        if command == "resource":
            result = query_voice_resources(ak=ak, sk=sk)
            print(json.dumps(result, ensure_ascii=False))
        elif command == "pool":
            result = query_number_pools(ak=ak, sk=sk)
            print(json.dumps(result, ensure_ascii=False))
        elif command == "send":
            if len(sys.argv) < 5:
                print("[voice-notify ERROR] send命令需要手机号、资源Key和号码池编号", file=sys.stderr)
                print("用法: python scripts/voice_notify.py send <手机号> <资源Key> <号码池编号>", file=sys.stderr)
                sys.exit(1)
            phone_number = sys.argv[2]
            resource_key = sys.argv[3]
            number_pool_no = sys.argv[4]
            result = create_voice_notify(
                ak=ak,
                sk=sk,
                phone_number=phone_number,
                resource=resource_key,
                number_pool_no=number_pool_no
            )
            print(json.dumps(result, ensure_ascii=False))
        else:
            print(f"[voice-notify ERROR] 未知命令: {command}", file=sys.stderr)
            print("可用命令: resource, pool, send", file=sys.stderr)
            sys.exit(1)
    except VmsError as e:
        print(f"[voice-notify ERROR] {str(e)}", file=sys.stderr)
        sys.exit(1)


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