Initial commit: OpenClaw Skill Collection

6 custom skills (assign-task, dispatch-webhook, daily-briefing,
task-capture, qmd-brain, tts-voice) with technical documentation.
Compatible with Claude Code, OpenClaw, Codex CLI, and OpenCode.
This commit is contained in:
2026-03-13 10:58:30 +08:00
commit 4c966a3ad2
884 changed files with 140761 additions and 0 deletions

192
skills/tts-voice/handler.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* tts-voice skill
* 文字轉語音:透過本機 LuxTTS API 合成語音
*
* 依賴:
* - LuxTTS server running on port 7860 (systemd user service)
* - ~/LuxTTS/.env (LUXTTS_USER, LUXTTS_PASS)
* - curl CLI
*/
import { execSync } from 'child_process';
import { readFileSync, existsSync, unlinkSync } from 'fs';
const LUXTTS_BASE = 'http://localhost:7860';
const REF_AUDIO = '/home/selig/LuxTTS/ref_speech.wav';
const ENV_PATH = '/home/selig/LuxTTS/.env';
const COOKIE_JAR = '/tmp/luxtts-skill-cookie';
// Trigger keywords to strip from user message
const TRIGGER_WORDS = [
'文字轉語音', '語音合成', '唸出來', '說出來', '轉語音',
'tts', 'voice',
];
// Speed/quality modifiers
const MODIFIERS: Record<string, Partial<TtsParams>> = {
'慢速': { speed: 0.8 },
'slow': { speed: 0.8 },
'快速': { speed: 1.3 },
'fast': { speed: 1.3 },
'高品質': { numSteps: 6, tShift: 0.95 },
'hq': { numSteps: 6, tShift: 0.95 },
};
interface TtsParams {
speed: number;
numSteps: number;
tShift: number;
}
/** Read credentials from .env */
function loadCredentials(): { user: string; pass: string } {
try {
const content = readFileSync(ENV_PATH, 'utf-8');
const user = content.match(/^LUXTTS_USER=(.+)$/m)?.[1]?.trim() || 'admin';
const pass = content.match(/^LUXTTS_PASS=(.+)$/m)?.[1]?.trim() || '';
return { user, pass };
} catch {
return { user: 'admin', pass: '' };
}
}
/** Ensure we have a valid session cookie */
function ensureCookie(): boolean {
const { user, pass } = loadCredentials();
if (!pass) return false;
try {
execSync(
`curl -s -o /dev/null -w "%{http_code}" -c ${COOKIE_JAR} ` +
`-d "username=${user}&password=${pass}" ` +
`${LUXTTS_BASE}/luxtts/login`,
{ timeout: 10000 }
);
return existsSync(COOKIE_JAR);
} catch {
return false;
}
}
/** Check if LuxTTS service is alive */
function healthCheck(): boolean {
try {
const result = execSync(
`curl -s -o /dev/null -w "%{http_code}" ${LUXTTS_BASE}/luxtts/api/health`,
{ timeout: 5000 }
).toString().trim();
return result === '200';
} catch {
return false;
}
}
/** Extract text and modifiers from user message */
function parseMessage(message: string): { text: string; params: TtsParams } {
let cleaned = message;
const params: TtsParams = { speed: 1.0, numSteps: 4, tShift: 0.9 };
// Remove trigger words
for (const trigger of TRIGGER_WORDS) {
const re = new RegExp(trigger, 'gi');
cleaned = cleaned.replace(re, '');
}
// Extract and apply modifiers
for (const [mod, overrides] of Object.entries(MODIFIERS)) {
const re = new RegExp(mod, 'gi');
if (re.test(cleaned)) {
cleaned = cleaned.replace(re, '');
Object.assign(params, overrides);
}
}
// Clean up
cleaned = cleaned.replace(/^[\s:,、]+/, '').replace(/[\s:,、]+$/, '').trim();
return { text: cleaned, params };
}
/** Call LuxTTS API to generate speech */
function generateSpeech(text: string, params: TtsParams): string | null {
const timestamp = Date.now();
const outPath = `/tmp/tts_output_${timestamp}.wav`;
try {
const httpCode = execSync(
`curl -s -o ${outPath} -w "%{http_code}" ` +
`-b ${COOKIE_JAR} ` +
`-X POST ${LUXTTS_BASE}/luxtts/api/tts ` +
`-F "ref_audio=@${REF_AUDIO}" ` +
`-F "text=${text.replace(/"/g, '\\"')}" ` +
`-F "num_steps=${params.numSteps}" ` +
`-F "t_shift=${params.tShift}" ` +
`-F "speed=${params.speed}"`,
{ timeout: 120000 } // 2 min timeout for CPU synthesis
).toString().trim();
if (httpCode === '200' && existsSync(outPath)) {
return outPath;
}
// Clean up failed output
if (existsSync(outPath)) unlinkSync(outPath);
return null;
} catch {
if (existsSync(outPath)) unlinkSync(outPath);
return null;
}
}
// ─── 主 handler ─────────────────────────────────────────────────────────────
export async function handler(ctx: any) {
const message = ctx.message?.text || ctx.message?.content || '';
if (!message.trim()) {
return { reply: '請提供要合成的文字例如「tts 你好,歡迎來到我的頻道」' };
}
// Parse user input
const { text, params } = parseMessage(message);
if (!text) {
return { reply: '請提供要合成的文字例如「tts 你好,歡迎來到我的頻道」' };
}
// Health check
if (!healthCheck()) {
return { reply: '❌ LuxTTS 服務未啟動,請先執行:`systemctl --user start luxtts`' };
}
// Ensure authentication
if (!ensureCookie()) {
return { reply: '❌ LuxTTS 認證失敗,請檢查 ~/LuxTTS/.env 的帳密設定' };
}
// Build parameter description
const paramDesc: string[] = [];
if (params.speed !== 1.0) paramDesc.push(`語速 ${params.speed}x`);
if (params.numSteps !== 4) paramDesc.push(`步數 ${params.numSteps}`);
const paramStr = paramDesc.length ? `${paramDesc.join('、')}` : '';
// Generate
const wavPath = generateSpeech(text, params);
if (!wavPath) {
return {
reply: `❌ 語音合成失敗,請稍後再試。`,
metadata: { text, params, error: true },
};
}
return {
reply: `🔊 語音合成完成${paramStr}\n\n📝 文字:${text}\n📂 檔案:\`${wavPath}\``,
metadata: {
text,
params,
output: wavPath,
},
files: [wavPath],
};
}