Skill: believe3344/voice-tts (version 2.0.1)

--- name: voice-tts description: 语音输入（Whisper ASR）+ 语音输出（Edge TTS）技能，支持 agent 专属音色，可调用 send_voice_reply.mjs 发送 Telegram 语音消息。 metadata: {"openclaw": {"emoji": "🎙️", "requires": {"bins": ["node>=18", "python3", "ffmpeg"], "pip": ["edge-tts", "whisper", "click"]}}} --- # voice-tts 语音输入（ASR）+ 语音输出（TTS）技能，完整替代 OpenClaw 内置 tts 工具处理中文内容。 ## 技术概览 | 方向 | 技术 | 说明 | |------|------|------| | 语音 → 文字 | Whisper（本地） | 接收语音，自动转文字 | | 文字 → 语音 | Edge TTS（云端） | 生成 MP3，发送 Telegram 语音消息 | --- ## 工作方式 ### ASR（语音 → 文字） 用户发来语音消息 → `voice-asr.mjs` 转写为文字 → 触发 agent 处理。 语音识别在 OpenClaw 工具层自动完成，agent 收到的是文字。 ### TTS（文字 → 语音） agent 回复文字后，如需以语音发送，调用 `send_voice_reply.mjs` 手动发送 Telegram 语音消息： ```bash node /path/to/voice-tts/scripts/send_voice_reply.mjs \ --text "你的回复内容" \ --chat-id 8317347201 \ --agent main ``` --- ## 快速安装 ### 方式一：一键安装（推荐） ```bash # 默认安装 turbo 模型 bash /path/to/voice-tts/install.sh # 国内加代理 bash /path/to/voice-tts/install.sh --proxy http://127.0.0.1:7897 ``` ### 方式二：手动安装 ```bash pip install edge-tts whisper click brew install ffmpeg # macOS sudo apt install -y ffmpeg # Ubuntu ``` 安装完成后运行冒烟测试： ```bash bash tests/smoke.sh ``` --- ## 配置（可选） `config.default.json` 已包含所有默认值，**不填配置可直接使用**。 如需自定义 agent 音色映射或 ASR 参数，在 `openclaw.json` 的 `skills.entries.voice-tts.config` 中覆盖： ```json { "skills": { "entries": { "voice-tts": { "enabled": true, "config": { "tts": { "defaultVoice": "zh-CN-XiaoxiaoNeural", "agentVoices": { "main": "zh-CN-XiaoxiaoNeural", "researcher": "zh-CN-YunxiNeural", "product": "zh-CN-XiaoyiNeural", "coder": "zh-CN-YunyangNeural", "devops": "zh-CN-YunjianNeural" } }, "asr": { "defaultInitialPrompt": "以下是中文语音转文字。常见词包括：管家、研究员、邮差、码农、产品、运维、OpenClaw、小爱、Telegram。", "defaultTemperature": 0, "conditionOnPreviousText": true } } } } } } ``` 修改后执行 `openclaw gateway restart`。 --- ## 核心脚本 ### 语音合成 — `bin/voice-tts.mjs` 将文字转为语音文件： ```bash # 基本用法 node bin/voice-tts.mjs "你好" -f /tmp/demo.mp3 # 指定 agent 音色 node bin/voice-tts.mjs "你好" -f /tmp/demo.mp3 --agent main # 指定声音 / 语速 node bin/voice-tts.mjs "你好" -f /tmp/demo.mp3 -v zh-CN-YunxiNeural -r +10% ``` 可用中文音色：`zh-CN-XiaoxiaoNeural`（女声，推荐）、`zh-CN-YunxiNeural`、`zh-CN-XiaoyiNeural`、`zh-CN-YunyangNeural`、`zh-CN-YunjianNeural`、`zh-CN-XiaomoNeural` ### 语音识别 — `bin/voice-asr.mjs` 将音频文件转文字： ```bash # 基本用法 node bin/voice-asr.mjs audio.ogg # 指定模型 / 语言 node bin/voice-asr.mjs audio.ogg --model turbo --language zh # 输出 JSON（含语言检测） node bin/voice-asr.mjs audio.ogg --json ``` 可用模型：`tiny` `base` `small` `turbo` `large-v3` ### 发送 Telegram 语音 — `scripts/send_voice_reply.mjs` 一键完成"文字 → TTS 合成 → Telegram 语音消息发送"： ```bash node scripts/send_voice_reply.mjs \ --text "已收到！" \ --chat-id 8317347201 \ --agent main ``` **参数说明：** | 参数 | 必填 | 说明 | |------|------|------| | `--text` | ✅ | 要语音播报的文字内容 | | `--chat-id` | ✅ | Telegram 目标用户 ID | | `--agent` | 否 | agent id，自动选对应音色 | | `--voice` | 否 | 覆盖默认音色，如 `zh-CN-YunxiNeural` | | `--rate` | 否 | 语速，如 `+10%`、`-5%` | | `--token` | 否 | 直接指定 Telegram Bot Token | **Token 自动查找优先级：** 1. `--token` 参数 2. `openclaw.json → channels.telegram.accounts.<当前agent>.botToken` 3. `openclaw.json → channels.telegram.accounts.default.botToken` 4. 环境变量 `TELEGRAM_BOT_TOKEN` --- ## 文件结构 ``` voice-tts/ ├── SKILL.md # 本文档 ├── config.default.json # 默认配置（直接可用，不需修改） ├── install.sh # 一键安装脚本 │ ├── bin/ │ ├── voice-tts.mjs # TTS 入口 │ └── voice-asr.mjs # ASR 入口 │ ├── lib/ │ ├── config.mjs # 配置读取（支持 openclaw.json 覆盖） │ ├── errors.mjs # 统一错误码 + 用户兜底消息 │ └── audio.mjs # 音频校验 │ ├── scripts/ │ ├── send_voice_reply.mjs # Telegram 语音发送（核心） │ └── auto_voice_check # 批量处理未处理语音 │ └── tests/ └── smoke.sh # 冒烟测试 ``` > **注意：** `scripts/edge_tts` 和 `scripts/whisper` 是内部 Python 封装，非直接入口；直接使用上表中的 `bin/` 入口即可。 --- ## 错误码 | 错误码 | 含义 | 用户兜底消息 | |--------|------|-------------| | `no_file_path` | 未提供音频文件 | 抱歉，没有收到音频文件，请重试。 | | `file_not_found` | 文件不存在 | 抱歉，音频文件没找到，请重试。 | | `file_empty` | 文件为空 | 抱歉，音频文件是空的，请重试。 | | `file_too_small` | 文件过小 | 抱歉，音频文件不完整，请重试。 | | `file_stale` | 文件过期 | 抱歉，音频文件已过期，请重试。 | | `transcription_failed` | Whisper 转写失败 | 抱歉，语音识别失败了，请重试。 | | `synthesis_failed` | Edge TTS 生成失败 | 抱歉，语音生成失败了，请重试。 | | `timeout` | 执行超时 | 抱歉，处理超时了，请稍后重试。 | --- ## 语音文件自动归档 `voice-asr.mjs` 成功转写后，自动将原文件从 `~/.openclaw/media/inbound/` 复制到 **agent workspace** `media/inbound/`，然后删除原文件。 - ✅ 成功时：复制归档，删除原文件 - ❌ 失败时：保留原文件，可重试 --- ## 故障排查 ```bash # 检查依赖 ffmpeg -version python3 -c "import edge_tts; print('edge-tts ok')" python3 -c "import whisper; print('whisper ok')" # 检查未处理语音文件 ls -la ~/.openclaw/media/inbound/ # 直接测试 ASR node bin/voice-asr.mjs ~/.openclaw/media/inbound/your-file.ogg # 直接测试 TTS node bin/voice-tts.mjs "测试" -f /tmp/test.mp3 # 运行冒烟测试 bash tests/smoke.sh ``` 常见问题： - **TTS 生成失败**：检查 `python3 -c "import edge_tts; print('ok')"` - **Telegram 发送失败**：确认 botToken 正确、chat-id 是数字 ID、语音文件 < 20MB - **语音发错对象**：检查 `conversationId` 是否与预期 chat-id 一致 --- ## 可选：批量处理未处理语音 ```bash node scripts/auto_voice_check ``` 检查 `~/.openclaw/media/inbound/` 下未处理的 `.ogg` 文件，自动转写并归档。

path: bin/voice-asr.mjs
content: #!/usr/bin/env node
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadConfig, getSkillRoot } from '../lib/config.mjs';
import { formatError } from '../lib/errors.mjs';
import { validateAudio } from '../lib/audio.mjs';

function parseArgs(argv) {
  const passthrough = [];
  let audioFile = null;
  for (let i = 0; i < argv.length; i++) {
    const [REDACTED_SECRET];
    if (!token) continue;
    if ((token === '--model' || token === '-m' || token === '--language' || token === '-l' || token === '--initial-prompt' || token === '--temperature') && i + 1 < argv.length) {
      passthrough.push(token, argv[i + 1]);
      i += 1;
      continue;
    }
    if (token === '--timestamps' || token === '-t' || token === '--json' || token === '-j' || token === '--quiet' || token === '-q' || token === '--condition-on-previous-text' || token === '--no-condition-on-previous-text') {
      passthrough.push(token);
      continue;
    }
    if (token.startsWith('-')) {
      passthrough.push(token);
      continue;
    }
    if (!audioFile) {
      audioFile = token;
    } else {
      passthrough.push(token);
    }
  }
  return { audioFile, passthrough };
}

async function main() {
  const { audioFile, passthrough } = parseArgs(process.argv.slice(2));
  const config = loadConfig();
  const validation = validateAudio(audioFile, config);
  if (!validation.ok) {
    console.error(JSON.stringify(formatError(validation.error)));
    process.exit(1);
  }

  // Inject default ASR parameters from config if not overridden
  if (!passthrough.includes('--initial-prompt') && config.asr?.defaultInitialPrompt) {
    passthrough.push('--initial-prompt', config.asr.defaultInitialPrompt);
  }
  if (!passthrough.includes('--temperature') && config.asr?.defaultTemperature !== undefined && config.asr?.defaultTemperature !== null) {
    passthrough.push('--temperature', String(config.asr.defaultTemperature));
  }
  if (!passthrough.includes('--condition-on-previous-text') && !passthrough.includes('--no-condition-on-previous-text') && config.asr?.conditionOnPreviousText !== undefined) {
    passthrough.push(config.asr.conditionOnPreviousText ? '--condition-on-previous-text' : '--no-condition-on-previous-text');
  }

  // Run Whisper transcription
  const script = path.join(getSkillRoot(), 'scripts', 'whisper');
  const child = spawn('python3', [script, audioFile, ...passthrough], { stdio: ['ignore', 'pipe', 'pipe'] });
  let stdout = '';
  let stderr = '';
  child.stdout.on('data', (d) => { stdout += d.toString(); });
  child.stderr.on('data', (d) => { stderr += d.toString(); });

  const timeout = setTimeout(() => child.kill('SIGKILL'), config.limits.asrTimeoutMs);

  await new Promise((resolve, reject) => {
    child.on('close', (code, signal) => {
      clearTimeout(timeout);
      if (signal === 'SIGKILL') {
        console.error(JSON.stringify(formatError('timeout')));
        reject(new Error('timeout'));
        return;
      }
      if (code !== 0) {
        let details = stderr.trim() || stdout.trim();
        try {
          const parsed = JSON.parse(details);
          if (parsed?.error) {
            console.error(JSON.stringify(formatError(parsed.error, parsed.details || '')));
            reject(new Error(parsed.error));
            return;
          }
        } catch {}
        console.error(JSON.stringify(formatError('transcription_failed', details)));
        reject(new Error('transcription_failed'));
        return;
      }
      resolve();
    });
    child.on('error', reject);
  });

  // ── Transcription succeeded — archive the file ──────────────────────
  // After successful ASR, move the file from ~/.openclaw/media/inbound/
  // to the agent's workspace media/inbound/, then delete the original.
  const inboundDir = path.join(os.homedir(), '.openclaw', 'media', 'inbound');
  if (audioFile.startsWith(inboundDir + path.sep) || audioFile === inboundDir) {
    const agentWorkspace = process.env.OPENCLAW_WORKSPACE || path.join(os.homedir(), '.openclaw', 'workspace');
    const agentInboundDir = path.join(agentWorkspace, 'media', 'inbound');
    const destPath = path.join(agentInboundDir, path.basename(audioFile));

    try {
      fs.mkdirSync(agentInboundDir, { recursive: true });
      // Copy first (in case delete fails, we still have the file)
      fs.copyFileSync(audioFile, destPath);
      // Delete original only after copy succeeds
      fs.unlinkSync(audioFile);
    } catch (err) {
      // Non-fatal: ASR already succeeded, file still exists at original path
      // Just log and continue — don't fail the transcription result
      console.error(`[voice-asr] archive warning: ${err.message}`);
    }
  }

  // Output formatted voice message — agent reads this and calls send_voice_reply.mjs
  const voiceMessage = `【语音消息】
执行要求：必须按照 voice-tts skill 的规则执行，回复需调用 send_voice_reply.mjs 返回语音+文字双通道格式
语音内容：${stdout.trim()}`;
  process.stdout.write(voiceMessage);
}

main().catch((err) => {
  console.error(JSON.stringify(formatError('transcription_failed', String(err))));
  process.exit(1);
});


path: bin/voice-tts.mjs
content: #!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { loadConfig, resolveVoice, getSkillRoot } from '../lib/config.mjs';
import { formatError } from '../lib/errors.mjs';

function parseArgs(argv) {
  const args = { text: '', output: '/tmp/voice.mp3', rate: null, pitch: null, voice: null, agent: null };
  const rest = [...argv];
  while (rest.length) {
    const [REDACTED_SECRET]);
    if (!token) continue;
    if (!args.text && !token.startsWith('-')) {
      args.text = token;
      continue;
    }
    if (token === '-f' || token === '--output') args.output = rest.shift();
    else if (token === '-v' || token === '--voice') args.voice = rest.shift();
    else if (token === '-r' || token === '--rate') args.rate = rest.shift();
    else if (token === '-p' || token === '--pitch') args.pitch = rest.shift();
    else if (token === '--agent') args.agent = rest.shift();
  }
  return args;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  if (!args.text) {
    console.error(JSON.stringify(formatError('synthesis_failed', 'missing_text')));
    process.exit(1);
  }

  const config = loadConfig();
  const voice = resolveVoice({ agentId: args.agent || process.env.OPENCLAW_AGENT_ID, explicitVoice: args.voice, config });
  const rate = args.rate || config.defaultRate;
  const pitch = args.pitch || config.defaultPitch;
  const output = path.resolve(args.output);
  fs.mkdirSync(path.dirname(output), { recursive: true });
  const tempOutput = `${output}.tmp`;

  const script = path.join(getSkillRoot(), 'scripts', 'edge_tts');
  const child = spawn('python3', [script, args.text, '-v', voice, '-f', tempOutput, '-r', rate, '-p', pitch], {
    stdio: ['ignore', 'pipe', 'pipe']
  });

  let stderr = '';
  child.stderr.on('data', (d) => { stderr += d.toString(); });

  const timeout = setTimeout(() => {
    child.kill('SIGKILL');
  }, config.limits.ttsTimeoutMs);

  child.on('close', (code) => {
    clearTimeout(timeout);
    if (code !== 0 || !fs.existsSync(tempOutput)) {
      console.error(JSON.stringify(formatError('synthesis_failed', stderr.trim())));
      process.exit(1);
    }
    fs.renameSync(tempOutput, output);
    process.stdout.write(`${output}\n`);
  });
}

main().catch((err) => {
  console.error(JSON.stringify(formatError('synthesis_failed', String(err))));
  process.exit(1);
});


path: config.default.json
content: {
  "defaultVoice": "zh-CN-XiaoxiaoNeural",
  "defaultRate": "+0%",
  "defaultPitch": "+0Hz",
  "agentVoices": {
    "main": "zh-CN-XiaoxiaoNeural"
  },
  "paths": {
    "mediaIn": "~/.openclaw/media/inbound",
    "mediaOut": "~/.openclaw/media/outbound",
    "processed": "~/.openclaw/media/voice_processed"
  },
  "asr": {
    "defaultInitialPrompt": "以下是中文语音转文字。常见词包括：管家、研究员、邮差、码农、产品、运维、OpenClaw、小爱、Telegram、QQBot、MiniMax、Home Assistant。",
    "defaultTemperature": 0,
    "conditionOnPreviousText": true
  },
  "limits": {
    "maxFileAgeSeconds": 300,
    "minFileSizeBytes": 1000,
    "ttsTimeoutMs": 60000,
    "asrTimeoutMs": 300000
  }
}


path: install.sh
content: #!/usr/bin/env bash
#
# voice-tts 安装脚本
# 用法: bash install.sh [--model MODEL] [--proxy PROXY]
#
# 示例:
#   bash install.sh                           # 默认安装 turbo 模型
#   bash install.sh --model base             # 安装 base 模型（更小更快）
#   bash install.sh --proxy http://127.0.0.1:7897  # 国内需代理
#

set -euo pipefail

# ── 参数解析 ──────────────────────────────────────
MODEL="turbo"
PROXY=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --model) MODEL="$2"; shift 2 ;;
    --proxy) PROXY="$2"; shift 2 ;;
    *) echo "未知参数: $1"; exit 1 ;;
  esac
done

# ── 颜色 ─────────────────────────────────────────
RED='\033[0;31m'; GRN='\033[0;32m'; YEL='\033[1;33m'; BLU='\033[0;34m'; NC='\033[0m'
info()  { echo -e "${BLU}[INFO]${NC} $1"; }
ok()    { echo -e "${GRN}[ OK ]${NC} $1"; }
warn()  { echo -e "${YEL}[WARN]${NC} $1"; }
err()   { echo -e "${RED}[ERR ]${NC} $1"; exit 1; }

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

# ── 0. 检查 Node.js ───────────────────────────────
info "检查 Node.js..."
command -v node >/dev/null 2>&1 || err "需要 Node.js >= 18，请先安装: https://nodejs.org/"
NODE_VER=$(node -v | tr -d 'v')
MAJOR_VER=$(echo "$NODE_VER" | cut -d. -f1)
[[ "$MAJOR_VER" -ge 18 ]] || err "Node.js 版本过低: $NODE_VER，需要 >= 18"
ok "Node.js $NODE_VER ✓"

# ── 1. 安装 Python 依赖 ───────────────────────────
info "安装 Python 依赖..."
command -v python3 >/dev/null 2>&1 || err "需要 python3，请先安装"

PIP_CMD="pip3 install edge-tts whisper click"
if [[ -n "$PROXY" ]]; then
  PIP_CMD="pip3 install -i https://pypi.tuna.tsinghua.edu.cn/simple edge-tts whisper click"
  export https_proxy="$PROXY" http_proxy="$PROXY"
fi

$PIP_CMD 2>&1 | tail -3
ok "Python 依赖安装完成 ✓"

# ── 2. 安装 ffmpeg ───────────────────────────────
info "检查 ffmpeg..."
if command -v ffmpeg >/dev/null 2>&1; then
  ok "ffmpeg 已安装: $(ffmpeg -version 2>&1 | head -1)"
else
  if [[ "$(uname)" == "Darwin" ]]; then
    info "检测到 macOS，尝试用 brew 安装 ffmpeg..."
    brew install ffmpeg 2>&1 | tail -3
  else
    info "检测到 Linux，尝试用 apt 安装 ffmpeg..."
    sudo apt install -y ffmpeg 2>&1 | tail -3
  fi
  ok "ffmpeg 安装完成 ✓"
fi

# ── 3. 下载 Whisper 模型 ─────────────────────────
info "下载 Whisper 模型: $MODEL (首次需要网络，模型约 800MB)..."
export HTTPS_PROXY="$PROXY"
export HTTP_PROXY="$PROXY"
python3 - <<EOF
import whisper
print(f"下载模型: $MODEL ...")
whisper.load_model("$MODEL")
print("下载完成 ✓")
EOF
ok "Whisper 模型 $MODEL 就绪 ✓"

# ── 4. 检查 skill 脚本权限 ───────────────────────
info "检查脚本权限..."
chmod +x "$SKILL_DIR/bin/voice-tts.mjs"
chmod +x "$SKILL_DIR/bin/voice-asr.mjs"
chmod +x "$SKILL_DIR/scripts/send_voice_reply.mjs"
chmod +x "$SKILL_DIR/scripts/auto_voice_check"
ok "脚本权限设置完成 ✓"

# ── 5. 生成 OpenClaw 配置片段 ─────────────────────
info "生成 OpenClaw 配置片段..."
OPENCLAW_CONF="$SKILL_DIR/openclaw_config_fragment.json"
cat > "$OPENCLAW_CONF" <<'CONF'
{
  "tools": {
    "media": {
      "audio": {
        "enabled": true,
        "models": [
          {
            "type": "cli",
            "command": "node",
            "args": [
              "SKILL_PATH_PLACEHOLDER/voice-tts/bin/voice-asr.mjs",
              "--model", "turbo",
              "--language", "zh",
              "{{MediaPath}}"
            ]
          }
        ]
      }
    }
  }
}
CONF

# ── 6. 生成 agent 音色配置片段（可选）──────────────
AGENT_VOICES_CONF="$SKILL_DIR/openclaw_agent_voices_fragment.json"
cat > "$AGENT_VOICES_CONF" <<'CONF'
{
  "skills": {
    "entries": {
      "voice-tts": {
        "enabled": true,
        "config": {
          "tts": {
            "defaultVoice": "zh-CN-XiaoxiaoNeural",
            "agentVoices": {
              "main":       "zh-CN-XiaoxiaoNeural",
              "researcher": "zh-CN-YunxiNeural",
              "product":    "zh-CN-XiaoyiNeural",
              "coder":      "zh-CN-YunyangNeural",
              "devops":     "zh-CN-YunjianNeural"
            }
          },
          "asr": {
            "defaultInitialPrompt": "以下是中文语音转文字。常见词包括：管家、研究员、邮差、码农、产品、运维、OpenClaw、小爱、Telegram。",
            "defaultTemperature": 0,
            "conditionOnPreviousText": true
          }
        }
      }
    }
  }
}
CONF

# ── 7. 验证安装 ───────────────────────────────────
info "验证安装..."
TTS_OUT=$(node "$SKILL_DIR/bin/voice-tts.mjs" "安装测试" -f /tmp/tts-test.mp3 --agent main 2>&1)
if [[ -f /tmp/tts-test.mp3 && -s /tmp/tts-test.mp3 ]]; then
  ok "TTS 生成验证通过 ✓"
  rm -f /tmp/tts-test.mp3
else
  warn "TTS 生成验证未通过，请检查 edge-tts 安装"
fi

# ── 8. 完成提示 ───────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "${GRN}✅ 安装完成！${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo -e "${BLU}下一步：将配置加入 openclaw.json${NC}"
echo ""
echo "  1. 打开 ~/.openclaw/openclaw.json"
echo "  2. 在对应位置加入以下内容："
echo ""
echo "  【必选】tools.media.audio（启用语音识别）："
echo "  → 已生成片段: $OPENCLAW_CONF"
echo ""
echo "  【可选】skills.entries.voice-tts.config（agent 音色映射）："
echo "  → 已生成片段: $AGENT_VOICES_CONF"
echo ""
echo -e "${BLU}重启 OpenClaw 使配置生效：${NC}"
echo "  openclaw gateway restart"
echo ""
echo -e "${BLB}测试语音识别：${NC}"
echo "  node $SKILL_DIR/bin/voice-asr.mjs <音频文件>"
echo ""
echo -e "${YEL}注意：send_voice_reply.mjs 需要 Telegram Bot Token，${NC}"
echo -e "${YEL}       国内需要配置代理访问 Telegram API${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"


path: lib/audio.mjs
content: import fs from 'node:fs';

export function validateAudio(filePath, config) {
  if (!filePath) return { ok: false, error: 'no_file_path' };
  if (!fs.existsSync(filePath)) return { ok: false, error: 'file_not_found' };

  const stats = fs.statSync(filePath);
  if (stats.size === 0) return { ok: false, error: 'file_empty' };
  if (stats.size < config.limits.minFileSizeBytes) return { ok: false, error: 'file_too_small' };

  const ageSeconds = (Date.now() - stats.mtimeMs) / 1000;
  if (ageSeconds > config.limits.maxFileAgeSeconds) return { ok: false, error: 'file_stale' };

  return { ok: true, path: filePath, size: stats.size, ageSeconds: Math.floor(ageSeconds) };
}


path: lib/config.mjs
content: import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, '..');
const openclawConfigPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');

function expandHome(p) {
  if (!p) return p;
  if (p === '~') return os.homedir();
  if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
  return p;
}

function deepMerge(base, extra) {
  if (!extra || typeof extra !== 'object' || Array.isArray(extra)) return base;
  const out = { ...base };
  for (const [k, v] of Object.entries(extra)) {
    if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) {
      out[k] = deepMerge(out[k], v);
    } else {
      out[k] = v;
    }
  }
  return out;
}

function tryLoadOpenClawSkillConfig() {
  try {
    if (!fs.existsSync(openclawConfigPath)) return {};
    const raw = fs.readFileSync(openclawConfigPath, 'utf8');
    const parsed = vm.runInNewContext(`(${raw})`, {});
    return parsed?.skills?.entries?.['voice-tts']?.config || {};
  } catch {
    return {};
  }
}

export function getSkillRoot() {
  return skillRoot;
}

export function loadConfig() {
  const defaultPath = path.join(skillRoot, 'config.default.json');
  const userPath = process.env.VOICE_TTS_CONFIG || path.join(skillRoot, 'config.json');
  const defaults = JSON.parse(fs.readFileSync(defaultPath, 'utf8'));
  const openclawSkillConfig = tryLoadOpenClawSkillConfig();
  const user = fs.existsSync(userPath) ? JSON.parse(fs.readFileSync(userPath, 'utf8')) : {};
  const merged = deepMerge(deepMerge(defaults, openclawSkillConfig), user);
  merged.paths = Object.fromEntries(Object.entries(merged.paths || {}).map(([k, v]) => [k, expandHome(v)]));
  return merged;
}

export function resolveVoice({ agentId, explicitVoice, config }) {
  const ttsConfig = config.tts || {};
  if (explicitVoice) return explicitVoice;
  if (agentId && ttsConfig.agentVoices?.[agentId]) return ttsConfig.agentVoices[agentId];
  if (agentId && config.agentVoices?.[agentId]) return config.agentVoices[agentId];
  return ttsConfig.defaultVoice || config.defaultVoice;
}


path: lib/errors.mjs
content: export const ERROR_MESSAGES = {
  no_file_path: '抱歉，没有收到音频文件，请重试。',
  file_not_found: '抱歉，音频文件没找到，可能是网络不稳定，请重试。',
  file_empty: '抱歉，音频文件是空的，可能是网络不稳定，请重试。',
  file_too_small: '抱歉，音频文件不完整，可能是网络不稳定，请重试。',
  file_stale: '抱歉，音频文件已过期，可能是网络不稳定，请重试。',
  transcription_failed: '抱歉，语音识别失败了，请重试。',
  synthesis_failed: '抱歉，语音生成失败了，请重试。',
  timeout: '抱歉，处理超时了，请稍后重试。'
};

export function formatError(error, details = '') {
  return {
    success: false,
    error,
    message: ERROR_MESSAGES[error] || ERROR_MESSAGES.transcription_failed,
    details
  };
}


path: package.json
content: {
  "name": "voice-tts-skill",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "description": "Voice TTS/ASR skill helpers for OpenClaw",
  "bin": {
    "voice-tts": "./bin/voice-tts.mjs",
    "voice-asr": "./bin/voice-asr.mjs"
  },
  "engines": {
    "node": ">=18"
  }
}


path: scripts/send_voice_reply.mjs
content: #!/usr/bin/env node
/**
 * send_voice_reply.mjs
 *
 * 接收文字回复内容，自动选择发送方式：
 * --channel telegram  → 直连 Telegram Bot API
 * --channel 其他       → 生成音频文件路径，agent 自行用 message 工具发送
 *
 * 用法：
 *   node send_voice_reply.mjs --text "回复内容" --chat-id "8317347201" --agent "main" --channel telegram
 *
 * Token 优先级（从上到下）：
 *   1. --token 参数
 *   2. openclaw.json 中当前 agent 的 botToken
 *   3. openclaw.json 中 default 账户的 botToken
 *   4. TELEGRAM_BOT_TOKEN 环境变量（兜底）
 */

import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { loadConfig, getSkillRoot, resolveVoice } from '../lib/config.mjs';

function parseArgs(argv) {
  const args = { text: '', chatId: '', agentId: 'main', voice: null, rate: null, token: null, channel: 'telegram' };
  for (let i = 0; i < argv.length; i++) {
    const [REDACTED_SECRET];
    if (!token) continue;
    if (token === '--text' || token === '-t') args.text = argv[++i];
    else if (token === '--chat-id' || token === '-c') args.chatId = argv[++i];
    else if (token === '--agent' || token === '-a') args.agentId = argv[++i];
    else if (token === '--voice' || token === '-v') args.voice = argv[++i];
    else if (token === '--rate' || token === '-r') args.rate = argv[++i];
    else if (token === '--token') args.[REDACTED_SECRET];
    else if (token === '--channel') args.channel = argv[++i];
  }
  return args;
}

function runCommand(cmd, args) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
    let stdout = '', stderr = '';
    child.stdout.on('data', d => { stdout += d.toString(); });
    child.stderr.on('data', d => { stderr += d.toString(); });
    child.on('close', (code) => {
      if (code === 0) resolve({ stdout, stderr });
      else reject(new Error(stderr || `exit ${code}`));
    });
    child.on('error', reject);
  });
}

function getBotToken(agentId) {
  try {
    const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
    if (!fs.existsSync(configPath)) return null;
    const raw = fs.readFileSync(configPath, 'utf8');
    const cfg = vm.runInNewContext(`(${raw})`, {});
    const accounts = cfg?.channels?.telegram?.accounts;
    if (!accounts) return null;
    return accounts[agentId]?.botToken || accounts['default']?.botToken || null;
  } catch {
    return null;
  }
}

async function synthesize(text, voice, rate) {
  const tempFile = `/tmp/voice-reply-${Date.now()}.mp3`;
  await runCommand('node', [
    path.join(getSkillRoot(), 'bin', 'voice-tts.mjs'),
    text, '-f', tempFile, '-v', voice, '-r', rate,
  ]);
  return tempFile;
}

async function sendTelegram(botToken, chatId, voiceFile, caption) {
  const apiUrl = `https://api.telegram.org/bot${botToken}/sendVoice`;
  const { stdout: httpCode } = await runCommand('curl', [
    '-s', '-o', '/dev/null', '-w', '%{http_code}',
    '-F', `chat_id=${chatId}`,
    '-F', `voice=@${voiceFile}`,
    '-F', `caption=${caption.slice(0, 1024)}`,
    apiUrl
  ]);
  return httpCode;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));

  if (!args.text) {
    console.error(JSON.stringify({ error: 'missing_text', message: '必须提供 --text 参数' }));
    process.exit(1);
  }
  if (!args.chatId && args.channel === 'telegram') {
    console.error(JSON.stringify({ error: 'missing_chat_id', message: 'Telegram 发送需要 --chat-id' }));
    process.exit(1);
  }

  const config = loadConfig();
  const voice = args.voice || resolveVoice({ agentId: args.agentId, explicitVoice: null, config });
  const rate = args.rate || config.tts?.defaultRate || '+0%';
  const channel = args.channel || 'telegram';

  console.error(`[send_voice_reply] channel=${channel}, voice=${voice}, rate=${rate}, agent=${args.agentId}, text="${args.text.slice(0, 50)}..."`);

  // Step 1: 生成 TTS
  let voiceFile;
  try {
    voiceFile = await synthesize(args.text, voice, rate);
  } catch (err) {
    console.error(JSON.stringify({ error: 'tts_failed', details: err.message }));
    process.exit(1);
  }

  if (!fs.existsSync(voiceFile) || fs.statSync(voiceFile).size === 0) {
    console.error(JSON.stringify({ error: 'tts_empty_file', message: 'TTS 生成空文件' }));
    process.exit(1);
  }

  // Step 2: 根据 channel 发送
  if (channel === 'telegram') {
    const explicitToken = args.token || process.env.TELEGRAM_BOT_TOKEN || null;
    const openclawToken = getBotToken(args.agentId);
    const botToken = explicitToken || openclawToken;
    if (!botToken) {
      console.error(JSON.stringify({
        error: 'missing_token',
        message: `未找到 Telegram Bot Token。请检查：\n` +
          `1. openclaw.json 中 channels.telegram.accounts.${args.agentId}.botToken\n` +
          `2. openclaw.json 中 channels.telegram.accounts.default.botToken\n` +
          `3. 或设置环境变量 TELEGRAM_BOT_TOKEN`
      }));
      process.exit(1);
    }
    const httpCode = await sendTelegram(botToken, args.chatId, voiceFile, args.text);
    fs.unlinkSync(voiceFile);
    if (httpCode !== '200') {
      console.error(JSON.stringify({ error: 'telegram_send_failed', httpCode }));
      process.exit(1);
    }
    console.error(`[send_voice_reply] Telegram 发送成功 → chat_id=${args.chatId}`);
    process.stdout.write(JSON.stringify({ ok: true, channel, chatId: args.chatId, voice, agent: args.agentId }));
  } else {
    // 非 Telegram：输出文件路径，由 agent 自行用 message 工具发送
    fs.unlinkSync(voiceFile);
    // 重新生成到 workspace 目录（message 工具允许的路径）
    const workspace = process.env.OPENCLAW_WORKSPACE || path.join(os.homedir(), '.openclaw', 'workspace');
    const mediaOut = path.join(workspace, 'media', 'outbound');
    fs.mkdirSync(mediaOut, { recursive: true });
    const outFile = path.join(mediaOut, `voice-${Date.now()}.mp3`);
    await runCommand('node', [
      path.join(getSkillRoot(), 'bin', 'voice-tts.mjs'),
      args.text, '-f', outFile, '-v', voice, '-r', rate,
    ]);

    const messageHint = channel === 'discord'
      ? `message(action="send", channel="discord", media="${outFile}", message="${args.text}")`
      : channel === 'whatsapp'
      ? `message(action="send", channel="whatsapp", media="${outFile}", message="${args.text}")`
      : `message(action="send", channel="${channel}", media="${outFile}", message="${args.text}")`;

    console.error(`[send_voice_reply] ${channel} 模式：音频已生成，请 agent 自行调用：\n${messageHint}`);
    process.stdout.write(JSON.stringify({
      ok: true,
      channel,
      file: outFile,
      agentHint: messageHint,
      voice,
      agent: args.agentId,
    }));
  }
}

main().catch(err => {
  console.error(JSON.stringify({ error: 'unexpected', details: String(err) }));
  process.exit(1);
});


path: tests/smoke.sh
content: #!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="/tmp/voice-tts-smoke.mp3"

echo "[1/3] validate TTS entrypoint"
node "$ROOT/bin/voice-tts.mjs" "你好，我是小叮当" -f "$OUT" --agent main >/tmp/voice-tts-smoke.log
test -f "$OUT" && echo "  TTS OK: $(ls -lh $OUT)"

echo "[2/3] validate ASR failure path (missing file)"
if node "$ROOT/bin/voice-asr.mjs" /tmp/not-exists-audio.ogg >/tmp/voice-asr.out 2>/tmp/voice-asr.err; then
  echo "ERROR: Expected ASR failure for missing file" >&2
  exit 1
fi
grep -q 'file_not_found' /tmp/voice-asr.err && echo "  ASR error handling OK"

echo "[3/3] validate send_voice_reply.mjs error path"
if node "$ROOT/scripts/send_voice_reply.mjs" >/tmp/send-voice.out 2>/tmp/send-voice.err; then
  echo "ERROR: Expected failure with missing args" >&2
  exit 1
fi
grep -q 'missing_text\|missing_chat_id' /tmp/send-voice.err && echo "  send_voice_reply error handling OK"

echo "SMOKE_OK"


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