Skill: lixiang92229/minimax-tts-feishu (version 1.0.2)

--- name: minimax-tts description: MiniMax 文字转语音，支持中文音色、自动情绪检测、语气词音效和停顿标记 metadata: {"openclaw": {"homepage": "https://github.com/lixiang92229/lx-minimax-tts-feishu"}} --- # minimax-tts MiniMax 文字转语音 Skill，支持中文音色选择、情绪检测、停顿标记。 ## 核心功能 ### 1. 文字转语音 (tts) 生成语音并发送到飞书，支持自动情绪检测和停顿标记。 ```bash tts_wrapper.sh tts "<文本>" [voice_id] [user_open_id] ``` ### 2. 音色设计 (voice_design) 通过描述创建自定义音色。 ```bash tts_wrapper.sh design "<音色描述>" "<试听文本>" "<要说的内容>" ``` ### 3. 查询音色列表 (list) ```bash tts_wrapper.sh list ``` ### 4. 更新音色目录 (update) 从 API 获取最新音色并更新本地 voices-map.md。 ```bash tts_wrapper.sh update ``` --- ## 交互式语音生成 ### 使用方式 在飞书对话中输入 **"转语音"** 即可触发。 - 直接发送：`转语音` → 将我最近一条消息转成语音 - 回复某条消息发送：`转语音` → 将那条消息转成语音 ### 支持的触发词 - 转语音 - 转成语音 - 变成语音 - 说一遍 - 语音播放 ### 工作原理 1. 用户说"转语音"后，系统从以下来源获取要转换的文字： - 被回复的那条消息（如果有） - 我最近发送的文字（保存在本地） 2. 对文字进行预处理（情绪检测 + 停顿标记） 3. 调用 MiniMax TTS API 生成语音 4. 将语音消息发送到飞书 --- ## 文本预处理功能 ### 自动情绪检测 系统会根据文本关键词自动判断情绪，并在 API 参数中使用： | 情绪 | 关键词示例 | |------|----------| | happy | 开心、高兴、太好了、哈哈、太棒了 | | sad | 伤心、难过、遗憾、可惜、唉 | | angry | 生气、气死了、讨厌、哼 | | fearful | 害怕、担心、恐怖 | | disgusted | 恶心、厌恶 | | surprised | 惊讶、吃惊、没想到 | | calm | 平静、淡定、好吧 | ### 停顿标记 文本会自动在标点后插入停顿标记，提升语音自然度： - `，` → 0.3秒停顿 - `。` → 0.5秒停顿 - `？` → 0.5秒停顿 - `！` → 0.5秒停顿 示例： - 原文：`真的吗？太好了！哈哈哈！` - 处理后：`真的吗？<#0.5#>太好了！<#0.5#>哈哈哈！<#0.5#>` - 情绪：happy ### 手动指定情绪 如需强制指定情绪，可传入 emotion 参数覆盖自动检测。 --- ## 环境变量 | 变量 | 说明 | |------|------| | `MINIMAX_API_KEY` | MiniMax API Key | | `FEISHU_APP_ID` | 飞书 App ID | | `FEISHU_APP_SECRET` | 飞书 App Secret | --- ## 文件结构 ``` skills/minimax-tts/ ├── SKILL.md ├── index.js └── scripts/ ├── tts.py # 核心 TTS 功能 ├── voice_design.py # 音色设计 ├── list_voices.py # 查询音色列表 ├── update_voices_map.py # 更新音色目录 ├── text_preprocessor.py # 文本预处理（情绪+停顿） ├── tts_from_chat.py # 对话式转语音 └── tts_wrapper.sh # 统一入口 ``` ## 默认音色 **Chinese (Mandarin)_Gentle_Senior（温柔学姐）** - 温柔、知性、略带亲切感的声音

path: README.md
content: # MiniMax TTS FeiShu Skill

[English](#english) | [中文](#中文)

---

## English

### Overview

This is an **OpenClaw Skill** for generating natural Chinese speech via MiniMax API and sending audio to Feishu. Features emotion detection, pause markers, and sound effect tags.

### Features

- **Text-to-Speech**: Convert text to natural Chinese speech
- **34+ Chinese Voices**: Built-in voice library with switching support
- **Automatic Emotion Detection**: Detects emotion (happy/sad/angry/surprised/calm) based on text keywords
- **Sound Effect Tags**: Auto-inserts laughter `(laughs)`, gasps `(gasps)`, sighs `(sighs)` etc.
- **Smart Pauses**: Automatically adds pause markers at punctuation (，。？！)
- **Feishu Integration**: Sends generated audio directly to Feishu chat
- **Conversational Trigger**: Users can say "转语音" to convert recent messages to speech

### Requirements

- **MiniMax API Key** — Get from [MiniMax Platform](https://platform.minimaxi.com)
- **Feishu App ID & Secret** — Create a Feishu app at [Feishu Open Platform](https://open.feishu.cn)
- **Feishu User Open ID** — Target user's Open ID for audio delivery
- OpenClaw environment

### Installation

1. Install via ClawHub:
```bash
npx clawhub install minimax-tts-feishu
```

2. Set required environment variables:
```bash
export MINIMAX_API_KEY="your-minimax-api-key"
export FEISHU_APP_ID="your-feishu-app-id"
export FEISHU_APP_SECRET="your-feishu-app-secret"
export FEISHU_USER_OPEN_ID="target-user-open-id"
```

### Quick Start

```bash
# Basic TTS
bash scripts/tts_wrapper.sh tts "要转换的文字"

# With specific voice
bash scripts/tts_wrapper.sh tts "文字内容" "Chinese (Mandarin)_Gentle_Senior"

# Design custom voice
bash scripts/tts_wrapper.sh design "温柔的女性声音" "这是一段试听文本" "要说的内容"

# List available voices
bash scripts/tts_wrapper.sh list

# Update voice catalog
bash scripts/tts_wrapper.sh update
```

### Parameters

| Command | Description |
|---------|-------------|
| `tts <text> [voice_id]` | Convert text to speech and send to Feishu |
| `design <prompt> <preview> <text>` | Design custom voice and generate speech |
| `list` | Show available voice list |
| `update` | Update local voice catalog from API |
| `save <text>` | Save last message for "转语音" trigger |
| `trigger <user_text> <open_id>` | Trigger TTS from chat context |

### Emotion Detection

The system automatically detects emotion based on text keywords:

| Emotion | Keywords |
|---------|----------|
| happy | 开心、高兴、太好了、哈哈、太棒了 |
| sad | 伤心、难过、可惜、算了、唉 |
| angry | 生气、讨厌、哼、气死了 |
| surprised | 惊讶、真的吗、什么、怎么 |
| calm | 平静、淡定、好吧、嗯 |

### Sound Effect Tags

| Tag | Effect |
|-----|--------|
| `(laughs)` | Laughter |
| `(gasps)` | Gasp / surprise |
| `(sighs)` | Sigh |
| `(clear-throat)` | Throat clear |

### ⚠️ Security Warnings

#### 1. API Key Security
- 🔐 You MUST set `MINIMAX_API_KEY`, `FEISHU_APP_ID`, `FEISHU_APP_SECRET` environment variables
- 🔐 Never commit API keys to version control
- 🔐 Use scoped API keys with billing limits when possible

#### 2. Feishu Credentials
- 🔐 Feishu App ID and Secret control bot behavior
- 🔐 `FEISHU_USER_OPEN_ID` determines where audio is sent
- 🔐 Do NOT expose these credentials publicly

#### 3. Network Access
- 🌐 This skill accesses `https://api.minimaxi.com` (MiniMax TTS)
- 🌐 This skill accesses `https://open.feishu.cn` (Feishu API)
- 🌐 Ensure your network allows access to the above domains

#### 4. File Write
- 📁 Voice catalog: `voices/voices-map.md` (configurable via `TTS_VOICES_MAP_PATH`)
- 📁 Audio output: `/tmp/` directory
- 📁 Last message cache: `/tmp/last_miss_m_message.txt`

#### 5. Audio Delivery
- 🔊 Generated audio is sent to the specified `FEISHU_USER_OPEN_ID`
- 🔊 Ensure the target user intends to receive TTS audio

### License

MIT License

---

## 中文

### 概述

这是面向 **OpenClaw** 的 **MiniMax 文字转语音 Skill**。通过 MiniMax API 生成自然中文语音并发送到飞书，支持情绪检测、停顿标记和语气词音效。

### 功能特点

- **文字转语音**：将文本转换为自然中文语音
- **34+ 中文音色**：内置音色库，支持查询和切换
- **自动情绪检测**：根据文本关键词自动判断情绪（开心/悲伤/愤怒/惊讶/平静等）
- **语气词音效**：自动插入笑声、叹气、惊讶等音效标签
- **智能停顿**：在标点处自动添加停顿标记，提升语音自然度
- **飞书集成**：将生成的语音直接发送到飞书聊天窗口
- **对话式触发**：用户说"转语音"可将最近的消息转换为语音

### 环境要求

- **MiniMax API Key** — 从 [MiniMax 开放平台](https://platform.minimaxi.com) 获取
- **飞书应用 App ID 和 Secret** — 在 [飞书开放平台](https://open.feishu.cn) 创建应用
- **飞书用户 Open ID** — 接收语音的目标用户 Open ID
- OpenClaw 环境

### 安装方式

1. 通过 ClawHub 安装：
```bash
npx clawhub install minimax-tts-feishu
```

2. 设置必需的环境变量：
```bash
export MINIMAX_API_KEY="your-minimax-api-key"
export FEISHU_APP_ID="your-feishu-app-id"
export FEISHU_APP_SECRET="your-feishu-app-secret"
export FEISHU_USER_OPEN_ID="target-user-open-id"
```

### 快速开始

```bash
# 基础文字转语音
bash scripts/tts_wrapper.sh tts "要转换的文字"

# 指定音色
bash scripts/tts_wrapper.sh tts "文字内容" "Chinese (Mandarin)_Gentle_Senior"

# 设计自定义音色
bash scripts/tts_wrapper.sh design "温柔的女性声音" "这是一段试听文本" "要说的内容"

# 查询可用音色
bash scripts/tts_wrapper.sh list

# 更新音色目录
bash scripts/tts_wrapper.sh update
```

### 命令说明

| 命令 | 说明 |
|------|------|
| `tts <文字> [音色ID]` | 将文字转换为语音并发送到飞书 |
| `design <描述> <试听文本> <内容>` | 设计自定义音色并生成语音 |
| `list` | 显示可用音色列表 |
| `update` | 从 API 更新本地音色目录 |
| `save <文字>` | 保存最后一条消息（用于"转语音"触发） |
| `trigger <用户消息> <用户OpenID>` | 从对话上下文触发 TTS |

### 情绪检测

系统根据文本关键词自动判断情绪：

| 情绪 | 关键词示例 |
|------|----------|
| happy（开心）| 开心、高兴、太好了、哈哈、太棒了 |
| sad（悲伤）| 伤心、难过、可惜、算了、唉 |
| angry（愤怒）| 生气、讨厌、哼、气死了 |
| surprised（惊讶）| 惊讶、真的吗、什么、怎么 |
| calm（平静）| 平静、淡定、好吧、嗯 |

### 语气词音效

| 标签 | 效果 |
|------|------|
| `(laughs)` | 笑声 |
| `(gasps)` | 倒吸气/惊讶 |
| `(sighs)` | 叹气 |
| `(clear-throat)` | 清嗓子 |

### 停顿标记

文本自动在标点后添加停顿：
- `，` → 0.3秒停顿
- `。？！` → 0.5秒停顿

### ⚠️ 安全风险提示

#### 1. API 密钥安全
- 🔐 **必须设置 `MINIMAX_API_KEY`、`FEISHU_APP_ID`、`FEISHU_APP_SECRET` 环境变量**
- 🔐 请勿将 API 密钥提交到版本控制系统
- 🔐 建议使用有限额的 API Key，定期轮换

#### 2. 飞书凭证安全
- 🔐 飞书 App ID 和 Secret 控制机器人行为
- 🔐 `FEISHU_USER_OPEN_ID` 决定语音发送目标
- 🔐 请勿公开这些凭证

#### 3. 网络访问
- 🌐 本 skill 会访问 `https://api.minimaxi.com`（MiniMax TTS 接口）
- 🌐 本 skill 会访问 `https://open.feishu.cn`（飞书 API）
- 🌐 请确认您的网络环境允许访问上述地址

#### 4. 文件写入
- 📁 音色目录：`voices/voices-map.md`（可通过 `TTS_VOICES_MAP_PATH` 环境变量配置）
- 📁 音频输出：`/tmp/` 目录
- 📁 最后消息缓存：`/tmp/last_miss_m_message.txt`

#### 5. 语音发送
- 🔊 生成的语音会发送到指定的 `FEISHU_USER_OPEN_ID`
- 🔊 请确认目标用户有意接收 TTS 语音

### 开源协议

MIT License


path: _meta.json
content: {
  "primaryEnv": "MINIMAX_API_KEY",
  "requiredEnvVars": ["MINIMAX_API_KEY", "FEISHU_APP_ID", "FEISHU_APP_SECRET"]
}


path: index.js
content: #!/usr/bin/env node
/**
 * minimax-tts skill entry point
 * 提供两个工具函数：
 *   1. design_voice - 设计音色
 *   2. tts_and_send - 生成语音并发送到飞书
 */

const { spawn } = require('child_process');
const path = require('path');

const scriptPath = path.join(__dirname, 'scripts', 'tts_wrapper.sh');

/**
 * 调用 Python 脚本执行 TTS
 */
function execPython(args) {
    return new Promise((resolve, reject) => {
        const proc = spawn('python3', [scriptPath, ...args], { cwd: __dirname });
        let stdout = '', stderr = '';
        proc.stdout.on('data', d => stdout += d);
        proc.stderr.on('data', d => stderr += d);
        proc.on('close', code => {
            if (code === 0) resolve(stdout.trim());
            else reject(new Error(stderr || `exit ${code}: ${stdout}`));
        });
    });
}

/**
 * 设计音色并生成语音发送
 * @param {string} prompt - 音色描述
 * @param {string} previewText - 试听文本
 * @param {string} text - 要说的内容
 */
async function designVoice(prompt, previewText, text) {
    return execPython(['design', prompt, previewText, text]);
}

/**
 * 直接生成语音发送
 * @param {string} text - 要说的内容
 * @param {string} voiceId - 音色ID
 */
async function speak(text, voiceId = 'male-qn-qingse') {
    return execPython(['tts', text, voiceId]);
}

module.exports = { designVoice, speak };


path: scripts/list_voices.py
content: #!/usr/bin/env python3
import os
"""
MiniMax TTS - 查询可用音色列表
"""

import requests
import json

[REDACTED_SECRET]"MINIMAX_API_KEY", "")


def list_voices(category: str = "system_voice", limit: int = 50) -> list:
    """
    查询 MiniMax 可用音色列表
    
    Args:
        category: 音色类型
            - system_voice: 系统音色（默认）
            - voice_cloning: 克隆音色
            - voice_generation: 生成音色
        limit: 返回数量限制
    
    Returns:
        音色列表
    """
    url = "https://api.minimaxi.com/v1/get_voice"
    payload = {"voice_type": "all"}
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {API_KEY}"
    }
    
    response = requests.post(url, json=payload, headers=headers, timeout=15)
    result = response.json()
    
    voices = result.get(category, [])
    return voices[:limit]


def format_voice_list(voices: list) -> str:
    """格式化音色列表为可读文本"""
    lines = []
    for v in voices:
        voice_id = v.get("voice_id", "")
        voice_name = v.get("voice_name", "")
        language = v.get("language", "")
        gender = v.get("gender", "")
        if voice_id and voice_name:
            lines.append(f"- **{voice_id}** — {voice_name} ({gender}, {language})")
    return "\n".join(lines) if lines else "未找到音色"


def main():
    print("正在获取 MiniMax 可用音色列表...\n")
    
    categories = {
        "system_voice": "系统音色",
        "voice_cloning": "克隆音色",
        "voice_generation": "生成音色",
    }
    
    for cat_key, cat_name in categories.items():
        voices = list_voices(category=cat_key, limit=20)
        if voices:
            print(f"=== {cat_name} ({len(voices)}个) ===")
            for v in voices[:20]:
                print(f"  {v.get('voice_id')}: {v.get('voice_name', '')}")
            if len(voices) > 20:
                print(f"  ... 还有 {len(voices)-20} 个")
            print()


if __name__ == "__main__":
    main()


path: scripts/text_preprocessor.py
content: #!/usr/bin/env python3
"""
TTS 文本预处理模块
根据语境自动判断情绪，并在文本中插入语气词标签和停顿标记
"""

import re


# 情绪关键词映射
EMOTION_KEYWORDS = {
    "happy": ["开心", "高兴", "太好了", "哈哈", "笑死", "太棒了", "嘿嘿", "嘻嘻", "哇", "哇塞", "真好", "喜欢", "爱你", "么么哒"],
    "sad": ["伤心", "难过", "遗憾", "可惜", "算了", "唉", "叹气", "沮丧", "失落", "委屈", "郁闷"],
    "angry": ["生气", "气死", "讨厌", "烦死了", "可恶", "气死我了", "哼", "哼唧"],
    "fearful": ["害怕", "担心", "恐惧", "恐怖", "吓人"],
    "disgusted": ["恶心", "厌恶", "吐了", "受不了"],
    "surprised": ["惊讶", "吃惊", "没想到", "哇", "天哪", "不会吧", "真的吗", "什么", "怎么", "为什么"],
    "calm": ["平静", "淡定", "冷静", "好吧", "嗯", "了解", "明白"],
    "fluent": ["那个", "然后", "接着", "其实"],
    "whisper": ["悄悄", "低声", "小声"],
}

# 语气词音效配置（按优先级）
SOUND_EFFECTS = [
    # 笑声 - 优先匹配长版本
    ("laughs", ["哈哈哈哈", "哈哈哈", "笑死", "嘿嘿嘿", "嘻嘻嘻", "呵呵呵"]),
    ("chuckle", ["嘿嘿", "嘻嘻", "咯咯"]),
    # 惊讶/感叹 - 句首
    ("gasps", ["哇塞", "天哪", "我靠", "我嚓", "不会吧", "真的吗"]),
    ("sighs", ["唉", "哎", "唉声叹气"]),
    # 单字语气词
    ("gasps", ["哇", "哎呀", "哎哟"]),
    # 思考
    ("clear-throat", ["嗯"]),
    # 软萌
    ("humming", ["么么哒", "嗯嗯", "好嘛", "好啦"]),
]


def detect_emotion(text: str) -> str | None:
    """
    根据文本关键词判断情绪
    返回情绪名称，或 None 表示使用默认
    """
    text_lower = text.lower()
    scores = {}
    
    for emotion, keywords in EMOTION_KEYWORDS.items():
        score = sum(1 for kw in keywords if kw in text_lower)
        if score > 0:
            scores[emotion] = score
    
    # 问号密度加权：问号多则倾向于 surprised
    question_count = text.count('？') + text.count('?')
    if question_count >= 2:
        scores['surprised'] = scores.get('surprised', 0) + question_count
    
    if not scores:
        return None
    
    # 返回得分最高的情绪
    return max(scores, key=scores.get)


def insert_single_sound(text: str) -> str:
    """
    插入单个语气词音效（避免重复插入）
    笑声插后面，语气词插前面
    如果文本中已有笑声词（如"哈哈哈"），不再插入laughs标签
    """
    # 笑声词列表
    laugh_words = ["哈哈", "嘿嘿", "嘻嘻", "呵呵", "嘎嘎", "笑死", "咯咯"]
    has_laugh = any(lw in text for lw in laugh_words)
    
    for sound_name, keywords in SOUND_EFFECTS:
        sound_tag = f"({sound_name})"
        if sound_tag in text:
            continue
        
        # 如果文本中已有笑声词，跳过laughs标签插入
        if sound_name == "laughs" and has_laugh:
            continue
            
        for kw in keywords:
            if kw not in text:
                continue
            
            idx = text.find(kw)
            
            if sound_name in ["laughs", "chuckle"]:
                # 笑声：插在关键词后面
                insert_pos = idx + len(kw)
                # 跳过标点
                while insert_pos < len(text) and text[insert_pos] in '，、。？！…… ,.：:':
                    insert_pos += 1
                # 避免插在句子中间（只在后半句或句尾）
                before_kw = idx
                after_content = text[insert_pos:].strip()
                if before_kw > 15 and not after_content.endswith(('。', '！', '？')) and after_content and '，' in after_content:
                    # 前半句，在最后一个逗号后插入
                    last_comma = after_content.rfind('，')
                    if last_comma > 0:
                        actual_pos = insert_pos + last_comma + 1
                        text = text[:actual_pos] + " " + sound_tag + text[actual_pos:]
                        return text
                # 否则直接在关键词后插入
                text = text[:insert_pos] + " " + sound_tag + text[insert_pos:]
                return text
            else:
                # 语气词：插在关键词前面
                text = text[:idx] + sound_tag + " " + text[idx:]
                return text
    
    return text


def insert_pause_markers(text: str) -> str:
    """
    在合适的位置插入停顿标记
    """
    patterns = [
        (r'，', '<#0.3#>'),
        (r'。', '<#0.5#>'),
        (r'？', '<#0.5#>'),
        (r'！', '<#0.5#>'),
        (r'…', '<#0.4#>'),
    ]
    
    result = text
    for pattern, marker in patterns:
        if marker not in result:
            result = re.sub(pattern, rf'{pattern}{marker}', result)
    
    return result


def preprocess_for_tts(text: str) -> dict:
    """
    完整的 TTS 文本预处理
    """
    # 1. 检测情绪
    emotion = detect_emotion(text)
    
    # 2. 插入单个语气词音效
    processed_text = insert_single_sound(text)
    
    # 3. 插入停顿标记
    processed_text = insert_pause_markers(processed_text)
    
    return {
        "text": processed_text,
        "emotion": emotion or "happy"
    }


def format_tts_payload(text: str, emotion: str = None) -> dict:
    """
    构造完整的 TTS API payload
    """
    processed = preprocess_for_tts(text)
    
    payload = {
        "model": "speech-2.8-hd",
        "text": processed["text"],
        "stream": False,
        "voice_setting": {
            "voice_id": "Chinese (Mandarin)_Gentle_Senior",
            "speed": 1.0,
            "vol": 1.0,
            "pitch": 0,
            "emotion": emotion or processed["emotion"]
        },
        "audio_setting": {
            "sample_rate": 16000,
            "format": "wav",
            "channel": 1
        },
        "subtitle_enable": False
    }
    
    return payload


if __name__ == "__main__":
    # 测试
    test_texts = [
        "你好想总，我是Miss M",
        "真的吗？太好了！哈哈哈！",
        "唉，这个有点难过了",
        "你讨厌啦，哼！",
        "哇，这也太厉害了吧！",
        "不会吧，还有这种操作？",
    ]
    
    for t in test_texts:
        result = preprocess_for_tts(t)
        print(f"原文: {t}")
        print(f"处理后: {result['text']}")
        print(f"情绪: {result['emotion']}")
        print()


path: scripts/tts.py
content: #!/usr/bin/env python3
"""
MiniMax TTS - 文字转语音模块
"""

import os
import requests
import json
import text_preprocessor

[REDACTED_SECRET]"MINIMAX_API_KEY", "")
FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
TTS_URL = "https://api.minimaxi.com/v1/t2a_v2"


def generate_speech(
    text: str,
    voice_id: str = "Chinese (Mandarin)_Gentle_Senior",
    speed: float = 1.0,
    vol: float = 1.0,
    pitch: float = 0,
    emotion: str = None,
    output_path: str = "/tmp/tts_output.wav"
) -> str:
    """
    使用 MiniMax TTS API 将文字转语音
    
    Args:
        text: 要转换的文本
        voice_id: 音色ID
        speed: 语速，默认 1.0
        vol: 音量，默认 1.0
        pitch: 音调，默认 0
        emotion: 情绪，happy/sad/angry 等，默认 None 表示自动检测
        output_path: 输出文件路径
    
    Returns:
        输出文件路径
    """
    # 文本预处理：自动检测情绪 + 添加停顿标记
    processed = text_preprocessor.preprocess_for_tts(text)
    detected_emotion = emotion if emotion is not None else processed["emotion"]
    processed_text = processed["text"]

    payload = {
        "model": "speech-2.8-hd",
        "text": processed_text,
        "stream": False,
        "voice_setting": {
            "voice_id": voice_id,
            "speed": speed,
            "vol": vol,
            "pitch": pitch,
            "emotion": detected_emotion
        },
        "audio_setting": {
            "sample_rate": 16000,
            "format": "wav",
            "channel": 1
        },
        "subtitle_enable": False
    }
    
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {API_KEY}"
    }
    
    response = requests.post(TTS_URL, json=payload, headers=headers, timeout=30)
    
    if response.status_code != 200:
        raise Exception(f"TTS API error: {response.status_code} - {response.text}")
    
    data = response.json()
    
    if "data" not in data or "audio" not in data.get("data", {}):
        raise Exception(f"Unexpected response: {data}")
    
    audio_hex = data["data"]["audio"]
    audio_bytes = bytes.fromhex(audio_hex)
    
    with open(output_path, "wb") as f:
        f.write(audio_bytes)
    
    return output_path


def _get_feishu_token() -> str:
    """获取飞书 access token"""
    req = requests.post(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
        timeout=10
    )
    return req.json()["tenant_access_token"]


def upload_to_feishu(file_path: str) -> str:
    """上传音频到飞书，返回 file_key"""
    [REDACTED_SECRET])
    
    with open(file_path, "rb") as f:
        upload_req = requests.post(
            "https://open.feishu.cn/open-apis/im/v1/files",
            headers={"Authorization": f"Bearer {token}"},
            files={"file": f},
            data={
                "file_name": os.path.basename(file_path),
                "file_extension": "wav",
                "file_type": "opus"
            },
            timeout=15
        )
    
    result = upload_req.json()
    if result.get("code") != 0:
        raise Exception(f"Feishu upload failed: {result}")
    
    return result["data"]["file_key"]


def send_audio_message(file_key: str, user_open_id: str = None) -> dict:
    """发送音频消息到飞书
    
    Args:
        file_key: 音频文件 key
        user_open_id: 接收者 open_id，默认从 FEISHU_USER_OPEN_ID 环境变量读取
    """
    if user_open_id is None:
        user_open_id = os.environ.get("FEISHU_USER_OPEN_ID", "")
    
    [REDACTED_SECRET])
    
    msg_req = requests.post(
        "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        },
        json={
            "receive_id": user_open_id,
            "msg_type": "audio",
            "content": json.dumps({"file_key": file_key})
        },
        timeout=15
    )
    
    return msg_req.json()


def tts_and_send(text: str, voice_id: str = "Chinese (Mandarin)_Gentle_Senior", user_open_id: str = None) -> dict:
    """生成 TTS 并发送"""
    output_path = "/tmp/tts_output.wav"
    generate_speech(text, voice_id=voice_id, output_path=output_path)
    file_key = upload_to_feishu(output_path)
    result = send_audio_message(file_key, user_open_id)
    return result


if __name__ == "__main__":
    import sys
    
    if len(sys.argv) < 2:
        print("用法: python3 tts.py <文本> [voice_id]")
        sys.exit(1)
    
    text = sys.argv[1]
    voice = sys.argv[2] if len(sys.argv) > 2 else "Chinese (Mandarin)_Gentle_Senior"
    user_open_id = sys.argv[3] if len(sys.argv) > 3 else None
    
    print(f"生成语音: {text}")
    print(f"音色: {voice}")
    if user_open_id:
        print(f"发送给: {user_open_id}")
    result = tts_and_send(text, voice, user_open_id)
    print(f"发送结果: {result}")


path: scripts/tts_from_chat.py
content: #!/usr/bin/env python3
"""
从对话中提取文字并转语音
用法: tts_from_chat.py "<用户消息文本>" <发送者open_id> [被回复的消息ID]

触发词：转语音 / 转成语音 / 说一遍 / 语音
"""

import sys
import os
import re

# 添加 scripts 目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

[REDACTED_SECRET]"MINIMAX_API_KEY", "")
FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")

LAST_MSG_FILE = "/tmp/last_miss_m_message.txt"


# 触发词
TRIGGERS = ["转语音", "转成语音", "变成语音", "说一遍", "语音播放"]


def is_triggered(user_text: str) -> bool:
    """检查是否包含触发词"""
    text = user_text.strip().lower()
    return any(t in text for t in TRIGGERS)


def get_feishu_token():
    """获取飞书 access token"""
    import requests
    r = requests.post(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
        timeout=10
    )
    return r.json().get("tenant_access_token", "")


def get_message_content(message_id: str, token: str) -> str:
    """获取指定消息的内容"""
    import requests
    try:
        r = requests.get(
            f"https://open.feishu.cn/open-apis/im/v1/messages/{message_id}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=10
        )
        data = r.json()
        if data.get("code") == 0:
            items = data.get("data", {}).get("items", [])
            if items:
                return items[0].get("body", {}).get("content", "")
    except Exception as e:
        print(f"获取消息失败: {e}", file=sys.stderr)
    return ""


def get_last_message() -> str:
    """获取我最后发的那条消息"""
    try:
        if os.path.exists(LAST_MSG_FILE):
            with open(LAST_MSG_FILE, "r", encoding="utf-8") as f:
                return f.read().strip()
    except Exception:
        pass
    return ""


def save_last_message(text: str):
    """保存最后一条消息"""
    try:
        with open(LAST_MSG_FILE, "w", encoding="utf-8") as f:
            f.write(text)
    except Exception:
        pass


def extract_and_speak(user_text: str, user_open_id: str = None, reply_message_id: str = None) -> dict:
    """
    主函数：检测触发词，提取文字，生成语音
    """
    if not is_triggered(user_text):
        return {"triggered": False}
    
    text_to_speak = ""
    
    # 优先：获取被回复的消息内容
    if reply_message_id:
        [REDACTED_SECRET])
        content = get_message_content(reply_message_id, token)
        if content:
            text_to_speak = content
            print(f"使用被回复消息 {reply_message_id}: {content[:50]}...", file=sys.stderr)
    
    # 其次：使用我最后发的那条消息
    if not text_to_speak:
        text_to_speak = get_last_message()
        if text_to_speak:
            print(f"使用最后消息: {text_to_speak[:50]}...", file=sys.stderr)
    
    if not text_to_speak:
        return {"triggered": True, "error": "找不到要转语音的文字"}
    
    # 生成并发送语音
    from tts import tts_and_send
    result = tts_and_send(text_to_speak, user_open_id=user_open_id)
    return {"triggered": True, "text": text_to_speak, "result": result}


if __name__ == "__main__":
    if len(sys.argv) < 2:
        # 无参数：保存最后一条消息
        print("用法:")
        print("  tts_from_chat.py save <消息文本>  # 保存最后消息")
        print("  tts_from_chat.py trigger <用户消息> <发送者open_id> [被回复消息ID]  # 触发转语音")
        sys.exit(0)
    
    cmd = sys.argv[1]
    
    if cmd == "save" and len(sys.argv) >= 3:
        # 保存最后一条消息
        save_last_message(sys.argv[2])
        print(f"已保存: {sys.argv[2][:50]}...")
    
    elif cmd == "trigger" and len(sys.argv) >= 3:
        user_text = sys.argv[2]
        user_open_id = sys.argv[3] if len(sys.argv) > 3 else None
        reply_message_id = sys.argv[4] if len(sys.argv) > 4 else None
        
        result = extract_and_speak(user_text, user_open_id, reply_message_id)
        print(result)
    
    else:
        print(f"未知命令或参数不足: {cmd}", file=sys.stderr)
        sys.exit(1)


path: scripts/tts_wrapper.sh
content: #!/bin/bash
# TTS wrapper - 支持多个子命令
# 用法:
#   tts_wrapper.sh tts "<文本>" [voice_id] [user_open_id]
#   tts_wrapper.sh design "<音色描述>" "<试听文本>" "<要说的内容>"
#   tts_wrapper.sh list
#   tts_wrapper.sh update
#   tts_wrapper.sh save "<文本>"           # 保存最后一条消息（用于转语音）
#   tts_wrapper.sh trigger "<用户消息>" <发送者open_id> [被回复消息ID]  # 触发转语音

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

case "$1" in
    tts)
        shift
        python3 "$SCRIPT_DIR/tts.py" "$@"
        ;;
    design)
        shift
        python3 "$SCRIPT_DIR/voice_design.py" "$@"
        ;;
    list)
        python3 "$SCRIPT_DIR/list_voices.py"
        ;;
    update)
        python3 "$SCRIPT_DIR/update_voices_map.py"
        ;;
    save)
        shift
        python3 "$SCRIPT_DIR/tts_from_chat.py" save "$@"
        ;;
    trigger)
        shift
        python3 "$SCRIPT_DIR/tts_from_chat.py" trigger "$@"
        ;;
    *)
        echo "用法:"
        echo "  tts_wrapper.sh tts <文本> [voice_id] [user_open_id]"
        echo "  tts_wrapper.sh design <音色描述> <试听文本> <要说的内容>"
        echo "  tts_wrapper.sh list"
        echo "  tts_wrapper.sh update"
        echo "  tts_wrapper.sh save <文本>"
        echo "  tts_wrapper.sh trigger <用户消息> <发送者open_id> [被回复消息ID]"
        exit 1
        ;;
esac


path: scripts/update_voices_map.py
content: #!/usr/bin/env python3
import os
"""
更新 voices-map.md
从 MiniMax API 获取最新音色列表，筛选中文音色后更新本地 voices-map.md
"""

import requests
import re
from datetime import datetime

[REDACTED_SECRET]"MINIMAX_API_KEY", "")
VOICES_MAP_PATH = os.environ.get("TTS_VOICES_MAP_PATH", os.path.join(os.path.dirname(os.path.dirname(__file__)), "voices-map.md"))


def get_all_voices():
    """从 API 获取所有音色"""
    url = "https://api.minimaxi.com/v1/get_voice"
    payload = {"voice_type": "all"}
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {API_KEY}"
    }
    response = requests.post(url, json=payload, headers=headers, timeout=15)
    result = response.json()
    voices = result.get("system_voice", [])
    return voices


def is_chinese_voice(voice: dict) -> bool:
    """判断是否为中文音色"""
    voice_name = voice.get("voice_name", "")
    voice_id = voice.get("voice_id", "")
    description = voice.get("description", [])

    chinese_keywords = ["中文", "普通话", "粤语", "Mandarin", "Chinese"]

    text = voice_name + " " + voice_id + " " + " ".join(description if isinstance(description, list) else [])

    for kw in chinese_keywords:
        if kw.lower() in text.lower():
            return True
    return False


def format_description(desc) -> str:
    """格式化描述字段"""
    if isinstance(desc, list):
        return " ".join(str(d) for d in desc if d)
    return str(desc) if desc else ""


def format_voices_table(voices: list) -> str:
    """格式化音色列表为 markdown 表格"""
    lines = [
        "| voice_id | 名称 | 描述 |",
        "|----------|------|------|"
    ]
    for v in voices:
        voice_id = v.get("voice_id", "")
        voice_name = v.get("voice_name", "")
        desc = format_description(v.get("description", ""))
        lines.append(f"| **{voice_id}** | {voice_name} | {desc[:40]} |")
    return "\n".join(lines)


def build_voices_map(voices: list, custom_voices: list = None) -> str:
    """构建完整的 voices-map.md 内容"""
    chinese_voices = [v for v in voices if is_chinese_voice(v)]
    now = datetime.now().strftime("%Y-%m-%d %H:%M")
    voice_table = format_voices_table(chinese_voices)

    custom_section = ""
    if custom_voices:
        custom_lines = [
            "",
            "## 自定义音色",
            "",
            "| voice_id | 创建时间 | 描述 | 状态 |",
            "|----------|----------|------|------|"
        ]
        for cv in custom_voices:
            custom_lines.append(
                f"| {cv.get('voice_id', '')} | {cv.get('created', '')} | "
                f"{cv.get('description', '')} | {cv.get('status', '')} |"
            )
        custom_section = "\n".join(custom_lines)

    content = f"""# 声音音色映射

更新时间：{now}

## 默认音色

**Chinese (Mandarin)_Gentle_Senior（温柔学姐）**

## 内置音色（中文，共 {len(chinese_voices)} 个）

| voice_id | 名称 | 描述 |
|----------|------|------|
{chr(10).join(f"| **{v.get('voice_id', '')}** | {v.get('voice_name', '')} | {format_description(v.get('description', ''))[:40]} |" for v in chinese_voices)}

{custom_section}

---
*本文件由 update_voices_map.py 自动更新*
"""
    return content


def main():
    print("正在获取最新音色列表...")
    voices = get_all_voices()
    print(f"获取到 {len(voices)} 个系统音色")

    chinese_voices = [v for v in voices if is_chinese_voice(v)]
    print(f"其中中文音色 {len(chinese_voices)} 个")

    # 读取现有的自定义音色
    existing_custom = []
    try:
        with open(VOICES_MAP_PATH, "r", encoding="utf-8") as f:
            content = f.read()
        custom_matches = re.findall(r"\| (ttv-voice-\S+) \| (\S+) \| (.+?) \| (.+?) \|", content)
        for match in custom_matches:
            existing_custom.append({
                "voice_id": match[0],
                "created": match[1],
                "description": match[2],
                "status": match[3]
            })
    except Exception:
        pass

    # 构建新内容（不使用 f-string 列表推导）
    table_lines = [
        "| voice_id | 名称 | 描述 |",
        "|----------|------|------|"
    ]
    for v in chinese_voices:
        voice_id = v.get("voice_id", "")
        voice_name = v.get("voice_name", "")
        desc = format_description(v.get("description", ""))[:40]
        table_lines.append(f"| **{voice_id}** | {voice_name} | {desc} |")

    custom_section = ""
    if existing_custom:
        custom_lines = [
            "",
            "## 自定义音色",
            "",
            "| voice_id | 创建时间 | 描述 | 状态 |",
            "|----------|----------|------|------|"
        ]
        for cv in existing_custom:
            custom_lines.append(
                f"| {cv.get('voice_id', '')} | {cv.get('created', '')} | "
                f"{cv.get('description', '')} | {cv.get('status', '')} |"
            )
        custom_section = "\n".join(custom_lines)

    now_str = datetime.now().strftime("%Y-%m-%d %H:%M")

    content = f"""# 声音音色映射

更新时间：{now_str}

## 默认音色

**Chinese (Mandarin)_Gentle_Senior（温柔学姐）**

## 内置音色（中文，共 {len(chinese_voices)} 个）

"""
    content += "\n".join(table_lines)
    content += f"""

{custom_section}

---
*本文件由 update_voices_map.py 自动更新*
"""

    with open(VOICES_MAP_PATH, "w", encoding="utf-8") as f:
        f.write(content)

    print(f"✅ 已更新 {VOICES_MAP_PATH}")
    print(f"   中文音色: {len(chinese_voices)} 个")
    if existing_custom:
        print(f"   自定义音色: {len(existing_custom)} 个")


if __name__ == "__main__":
    main()


path: scripts/voice_design.py
content: #!/usr/bin/env python3
import os
"""
MiniMax TTS - 音色设计模块
"""

import requests
from tts import tts_and_send, generate_speech, upload_to_feishu

[REDACTED_SECRET]"MINIMAX_API_KEY", "")
VOICE_DESIGN_URL = "https://api.minimaxi.com/v1/voice_design"


def design_voice(prompt: str, preview_text: str = "测试") -> dict:
    """
    使用 MiniMax voice_design API 设计新音色
    
    Args:
        prompt: 音色描述，如"温柔的女性声音，语速适中"
        preview_text: 试听文本
    
    Returns:
        dict，包含 voice_id 和 preview_audio_bytes
    """
    payload = {
        "prompt": prompt,
        "preview_text": preview_text
    }
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {API_KEY}"
    }
    
    response = requests.post(VOICE_DESIGN_URL, json=payload, headers=headers, timeout=60)
    data = response.json()
    
    if data.get("base_resp", {}).get("status_code") != 0:
        raise Exception(f"Voice design failed: {data}")
    
    result = {
        "voice_id": data.get("voice_id"),
        "preview_audio": bytes.fromhex(data["trial_audio"]) if "trial_audio" in data and data["trial_audio"] else None
    }
    
    return result


def design_and_send(prompt: str, preview_text: str, text: str) -> dict:
    """
    设计音色 + 生成语音并发送
    
    Args:
        prompt: 音色描述
        preview_text: 试听文本
        text: 要说的内容
    
    Returns:
        dict，包含 voice_id 和发送结果
    """
    # 1. 设计音色
    voice_info = design_voice(prompt, preview_text)
    voice_id = voice_info["voice_id"]
    
    # 2. 用新音色生成语音
    output_path = "/tmp/tts_output.wav"
    generate_speech(text, voice_id=voice_id, output_path=output_path)
    
    # 3. 上传并发送
    file_key = upload_to_feishu(output_path)
    result = send_audio_message(file_key)
    
    return {"voice_id": voice_id, "result": result}


if __name__ == "__main__":
    import sys
    
    if len(sys.argv) < 3:
        print("用法: python3 voice_design.py <音色描述> <试听文本> <要说的内容>")
        sys.exit(1)
    
    prompt = sys.argv[1]
    preview = sys.argv[2]
    text = sys.argv[3]
    
    print(f"设计音色: {prompt}")
    print(f"试听文本: {preview}")
    print(f"生成内容: {text}")
    result = design_and_send(prompt, preview, text)
    print(f"发送结果: {result}")


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