Skill: helloeveryworlds/feishu-voice-tts (version 1.1.1)

--- name: feishu-voice-tts description: 将文本通过 MOSS-TTS 转换为语音，并发送到飞书群/个人。支持语音消息格式（带波形条）。 --- # 飞书语音 TTS 技能 当用户要求"给飞书发语音"、"在飞书里读这段话"或类似需求时，执行以下流程： ## 核心流程 1. **MOSS-TTS 生成 WAV** → 2. **ffmpeg 转 opus** → 3. **上传飞书** → 4. **发送语音消息** ## 前置要求 - 系统已安装 `ffmpeg` - 已配置环境变量 `MOSS_API_KEY`（MOSS-TTS API Key） - 飞书配置在 OpenClaw 安装时已自动完成 ## 使用方法 ### 命令行模式 ```bash # 基本用法 python scripts/feishu_tts.py --text "要发送的文本" --chat_id "飞书群ID" # 指定音色 python scripts/feishu_tts.py --text "文本" --voice_id "音色ID" --chat_id "群ID" # 发送给个人（需要 open_id） python scripts/feishu_tts.py --text "文本" --receive_id "用户open_id" --receive_id_type "open_id" ``` ### 参数说明 | 参数 | 必填 | 说明 | |------|------|------| | `--text` | ✅ | 要转语音的文本 | | `--chat_id` | ❌ | 飞书群 ID（与 receive_id 二选一） | | `--receive_id` | ❌ | 接收者 ID（open_id 或 chat_id） | | `--receive_id_type` | ❌ | 接收者类型：`chat_id` 或 `open_id`，默认 `chat_id` | | `--voice_id` | ❌ | MOSS 音色 ID，默认 `2001286865130360832`（周周） | | `--output` | ❌ | 输出文件路径，默认 `feishu_voice.wav` | ## 示例 ```bash # 给飞书群发语音 python scripts/feishu_tts.py --text "你好，我是 AI 助手" --chat_id "oc_xxx" # 给指定用户发语音 python scripts/feishu_tts.py --text "你好" --receive_id "ou_xxx" --receive_id_type "open_id" ``` ## 飞书权限要求 确保飞书应用已开通以下权限： - `im:message:send_as_bot` - 以机器人身份发送消息 - `drive:file:upload` - 上传文件（发送语音消息必需） - `im:message.group:readonly` - 读取群组消息（获取历史记录必需） - `im:chat:readonly` - 读取会话列表 > 注意：需要在飞书开放平台应用配置中添加 `drive:file:upload` 权限并发布。 ## 获取消息历史 ### 脚本：get_history.py 获取飞书群消息历史，按时间排序。 ```bash # 获取最近 24 小时的消息 python scripts/get_history.py --chat_id "oc_xxx" # 获取最近 1 小时的消息 python scripts/get_history.py --chat_id "oc_xxx" --hours 1 # 获取所有消息 python scripts/get_history.py --chat_id "oc_xxx" --all # 只显示 audio 类型的消息 python scripts/get_history.py --chat_id "oc_xxx" --type audio # 显示最近 10 条 python scripts/get_history.py --chat_id "oc_xxx" --limit 10 ``` ### 参数说明 | 参数 | 必填 | 说明 | |------|------|------| | `--chat_id` | ✅ | 飞书群 ID | | `--hours` | ❌ | 获取最近几小时的消息，默认 24 | | `--all` | ❌ | 获取所有消息（不使用时间过滤） | | `--limit` | ❌ | 显示最近多少条消息，默认 30 | | `--type` | ❌ | 只显示指定类型的消息，如 audio, file, text, image | ### 环境变量 需要设置飞书应用认证： ```bash FEISHU_APP_ID=你的应用ID FEISHU_APP_SECRET=你的应用密钥 ```

path: _meta.json
content: ownerId: kn703w9nged7r8agx5xdah299s8278jv
slug: feishu-voice-tts
version: 1.1.0
publishedAt: 1772542043965

path: scripts/feishu_tts.py
content: #!/usr/bin/env python3
"""飞书语音 TTS 发送脚本：MOSS-TTS + ffmpeg 转码 + 飞书上传 + 发送语音消息"""

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

import requests

MOSS_API_KEY = os.getenv("MOSS_API_KEY")
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID")
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET")

DEFAULT_VOICE_ID = "2001286865130360832"  # 周周
TTS_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "tts.py")
TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
UPLOAD_URL = "https://open.feishu.cn/open-apis/im/v1/files"


def fail(msg: str, code: int = 1):
    print(f"Error: {msg}", file=sys.stderr)
    raise SystemExit(code)


def _json(resp: requests.Response):
    try:
        return resp.json()
    except ValueError:
        fail(f"non-JSON response (HTTP {resp.status_code}): {resp.text[:300]}")


def get_tenant_access_token(timeout=30):
    try:
        resp = requests.post(TOKEN_URL, json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET}, timeout=timeout)
    except requests.RequestException as e:
        fail(f"获取 token 请求失败: {e}")

    data = _json(resp)
    if resp.status_code >= 400 or data.get("code") != 0:
        fail(f"获取 token 失败: {data}")
    return data["tenant_access_token"]


def generate_tts(text, voice_id, output_path):
    cmd = [
        sys.executable,
        TTS_SCRIPT_PATH,
        "--text", text,
        "--voice_id", voice_id,
        "--output", output_path,
    ]
    env = os.environ.copy()
    env["MOSS_API_KEY"] = MOSS_API_KEY
    result = subprocess.run(cmd, capture_output=True, text=True, env=env)
    if result.returncode != 0:
        fail(f"TTS 生成失败: {result.stderr or result.stdout}")
    print(f"✓ TTS 生成完成: {output_path}")


def convert_to_opus(input_wav, output_opus):
    if not shutil.which("ffmpeg"):
        fail("未找到 ffmpeg，请先安装")
    cmd = [
        "ffmpeg", "-y", "-i", input_wav,
        "-ac", "1", "-ar", "16000",
        "-c:a", "libopus", "-b:a", "24k", output_opus,
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        fail(f"音频转码失败: {result.stderr}")
    print(f"✓ 转码完成: {output_opus}")


def upload_to_feishu(token, file_path, timeout=60):
    headers = {"Authorization": f"Bearer {token}"}
    with open(file_path, "rb") as f:
        data = {"file_type": "opus", "file_name": os.path.basename(file_path)}
        files = {"file": (os.path.basename(file_path), f, "audio/ogg")}
        try:
            resp = requests.post(UPLOAD_URL, headers=headers, data=data, files=files, timeout=timeout)
        except requests.RequestException as e:
            fail(f"文件上传请求失败: {e}")

    payload = _json(resp)
    if resp.status_code >= 400 or payload.get("code") != 0:
        fail(f"文件上传失败: {payload}")

    file_key = payload.get("data", {}).get("file_key")
    if not file_key:
        fail(f"上传返回缺少 file_key: {payload}")
    print(f"✓ 文件上传成功: {file_key}")
    return file_key


def send_voice_message(token, receive_id, receive_id_type, file_key, timeout=30):
    url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}"
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    payload = {
        "receive_id": receive_id,
        "msg_type": "audio",
        "content": json.dumps({"file_key": file_key}),
    }
    try:
        resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
    except requests.RequestException as e:
        fail(f"发送消息请求失败: {e}")

    data = _json(resp)
    if resp.status_code >= 400 or data.get("code") != 0:
        fail(f"发送消息失败: {data}")

    message_id = data.get("data", {}).get("message_id")
    if not message_id:
        fail(f"发送返回缺少 message_id: {data}")
    print(f"✓ 语音消息发送成功: {message_id}")
    return data["data"]


def main():
    parser = argparse.ArgumentParser(description="飞书语音 TTS 发送")
    parser.add_argument("--text", required=True, help="要转语音的文本")
    parser.add_argument("--chat_id", help="飞书群 ID")
    parser.add_argument("--receive_id", help="接收者 ID")
    parser.add_argument("--receive_id_type", default="chat_id", choices=["chat_id", "open_id"], help="接收者类型")
    parser.add_argument("--voice_id", default=DEFAULT_VOICE_ID, help="MOSS 音色 ID")
    parser.add_argument("--output", default="feishu_voice.wav", help="输出文件路径")
    args = parser.parse_args()

    if not args.text.strip():
        fail("--text 不能为空")

    if args.chat_id and args.receive_id:
        fail("--chat_id 与 --receive_id 只能二选一")

    if args.chat_id:
        receive_id = args.chat_id
        receive_id_type = "chat_id"
    elif args.receive_id:
        receive_id = args.receive_id
        receive_id_type = args.receive_id_type
    else:
        fail("必须指定 --chat_id 或 --receive_id")

    if not MOSS_API_KEY:
        fail("未设置 MOSS_API_KEY 环境变量")
    if not FEISHU_APP_ID or not FEISHU_APP_SECRET:
        fail("未设置 FEISHU_APP_ID 或 FEISHU_APP_SECRET 环境变量")
    if not Path(TTS_SCRIPT_PATH).exists():
        fail(f"未找到 tts 脚本: {TTS_SCRIPT_PATH}")

    with tempfile.TemporaryDirectory() as tmpdir:
        wav_path = os.path.join(tmpdir, "tts.wav")
        opus_path = os.path.join(tmpdir, "voice.opus")

        print(f"正在生成语音: {args.text[:50]}...")
        generate_tts(args.text, args.voice_id, wav_path)

        print("正在转码为 OPUS...")
        convert_to_opus(wav_path, opus_path)

        print("正在获取飞书 token...")
        [REDACTED_SECRET])

        print("正在上传到飞书...")
        file_key = upload_to_feishu(token, opus_path)

        print("正在发送语音消息...")
        send_voice_message(token, receive_id, receive_id_type, file_key)

    print("\n✅ 全部完成！")


if __name__ == "__main__":
    main()


path: scripts/get_history.py
content: #!/usr/bin/env python3
"""获取飞书群消息历史，按时间排序"""
import argparse
import os
import time
from datetime import datetime

import requests

TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"


def fail(msg: str, code: int = 1):
    raise SystemExit(f"Error: {msg}")


def _json(resp: requests.Response):
    try:
        return resp.json()
    except ValueError:
        fail(f"non-JSON response (HTTP {resp.status_code}): {resp.text[:300]}")


def get_token(timeout=30):
    app_id = os.getenv("FEISHU_APP_ID")
    app_secret = os.getenv("FEISHU_APP_SECRET")
    if not app_id or not app_secret:
        fail("请设置 FEISHU_APP_ID 和 FEISHU_APP_SECRET 环境变量")

    try:
        resp = requests.post(TOKEN_URL, json={"app_id": app_id, "app_secret": app_secret}, timeout=timeout)
    except requests.RequestException as e:
        fail(f"获取 token 请求失败: {e}")

    data = _json(resp)
    if resp.status_code >= 400 or data.get("code") != 0:
        fail(f"获取 token 失败: {data}")
    return data["tenant_access_token"]


def _fetch_messages(token, chat_id, page_size=50, start_time=None, end_time=None, timeout=30):
    all_messages = []
    page_token = None
    headers = {"Authorization": f"Bearer {token}"}

    while True:
        params = {
            "container_id_type": "chat",
            "container_id": chat_id,
            "page_size": page_size,
        }
        if start_time is not None:
            params["start_time"] = int(start_time)
        if end_time is not None:
            params["end_time"] = int(end_time)
        if page_token:
            params["page_token"] = page_token

        try:
            resp = requests.get(MSG_URL, headers=headers, params=params, timeout=timeout)
        except requests.RequestException as e:
            fail(f"获取消息请求失败: {e}")

        data = _json(resp)
        if resp.status_code >= 400 or data.get("code") != 0:
            fail(f"获取消息失败: {data}")

        body = data.get("data", {})
        items = body.get("items", [])
        if not isinstance(items, list):
            fail(f"响应格式异常: {data}")
        all_messages.extend(items)

        if not body.get("has_more"):
            break
        page_token = body.get("page_token")
        if not page_token:
            break

    return all_messages


def main():
    parser = argparse.ArgumentParser(description="获取飞书群消息历史")
    parser.add_argument("--chat_id", required=True, help="飞书群 ID")
    parser.add_argument("--hours", type=int, default=24, help="获取最近几小时的消息，默认 24 小时")
    parser.add_argument("--all", action="store_true", help="获取所有消息（不使用时间过滤）")
    parser.add_argument("--limit", type=int, default=30, help="显示最近多少条消息，默认 30 条")
    parser.add_argument("--type", help="只显示指定类型的消息，如 audio, file, text, image 等")
    args = parser.parse_args()

    if args.hours <= 0:
        fail("--hours 必须大于 0")
    if args.limit <= 0:
        fail("--limit 必须大于 0")

    print("获取 token...")
    [REDACTED_SECRET])

    if args.all:
        print("获取全部消息...")
        messages = _fetch_messages(token, args.chat_id)
    else:
        now = int(time.time())
        start_time = now - (args.hours * 3600)
        print(f"获取最近 {args.hours} 小时消息...")
        messages = _fetch_messages(token, args.chat_id, start_time=start_time, end_time=now)

    print(f"共获取 {len(messages)} 条消息")
    messages.sort(key=lambda x: int(x.get("create_time", 0)), reverse=True)

    if args.type:
        messages = [m for m in messages if m.get("msg_type") == args.type]
        print(f"过滤后 {len(messages)} 条消息")

    print(f"\n最近 {min(args.limit, len(messages))} 条消息:")
    print("-" * 60)
    for i, m in enumerate(messages[: args.limit], start=1):
        ts = int(m.get("create_time", 0)) / 1000 if m.get("create_time") else 0
        dt = datetime.fromtimestamp(ts) if ts else "N/A"
        print(f"{i:2}. {m.get('msg_type', 'unknown'):12} | {dt} | {m.get('message_id', 'N/A')}")
        if m.get("msg_type") in ["audio", "file", "image"]:
            content = str(m.get("body", {}).get("content", ""))[:100]
            print(f"    {content}")


if __name__ == "__main__":
    main()


path: scripts/tts.py
content: #!/usr/bin/env python3
import argparse
import base64
import os
import sys
from pathlib import Path

import requests


DEFAULT_VOICE_ID = "2001286865130360832"
TTS_URL = "https://studio.mosi.cn/v1/audio/tts"


def fail(msg: str, code: int = 1):
    print(f"Error: {msg}", file=sys.stderr)
    raise SystemExit(code)


def main():
    parser = argparse.ArgumentParser(description="Call MOSS-TTS and save wav file")
    parser.add_argument("--text", required=True)
    parser.add_argument("--voice_id", default=DEFAULT_VOICE_ID)
    parser.add_argument("--output", default="output.wav")
    parser.add_argument("--timeout", type=int, default=90)
    args = parser.parse_args()

    if not args.text.strip():
        fail("--text cannot be empty")

    [REDACTED_SECRET]"MOSS_API_KEY")
    if not api_key:
        fail("MOSS_API_KEY is not set")

    out_path = Path(args.output)
    if out_path.parent and not out_path.parent.exists():
        out_path.parent.mkdir(parents=True, exist_ok=True)

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload = {
        "model": "moss-tts",
        "text": args.text,
        "voice_id": args.voice_id,
        "sampling_params": {"temperature": 1.7, "top_p": 0.8, "top_k": 25},
    }

    try:
        resp = requests.post(TTS_URL, json=payload, headers=headers, timeout=args.timeout)
    except requests.RequestException as e:
        fail(f"request failed: {e}")

    if resp.status_code != 200:
        fail(f"HTTP {resp.status_code}: {resp.text[:300]}")

    try:
        data = resp.json()
    except ValueError:
        fail(f"non-JSON response: {resp.text[:300]}")

    audio_data = data.get("audio_data")
    if not audio_data:
        fail(f"missing audio_data in response: {data}")

    try:
        audio_bytes = base64.b64decode(audio_data)
    except Exception as e:
        fail(f"invalid base64 audio_data: {e}")

    out_path.write_bytes(audio_bytes)
    print(f"Successfully saved to {out_path}")


if __name__ == "__main__":
    main()


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