claude-phone-local 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +108 -0
- package/Dockerfile +84 -0
- package/LICENSE +22 -0
- package/README.md +231 -0
- package/claude-api-server/package-lock.json +832 -0
- package/claude-api-server/package.json +20 -0
- package/claude-api-server/server.js +540 -0
- package/claude-api-server/structured.js +208 -0
- package/cli/README.md +231 -0
- package/cli/bin/check-publish-safety.js +53 -0
- package/cli/bin/claude-phone.js +23 -0
- package/cli/bin/cli-main.js +284 -0
- package/cli/bin/postinstall.js +20 -0
- package/cli/lib/commands/api-server.js +77 -0
- package/cli/lib/commands/backup.js +69 -0
- package/cli/lib/commands/config/path.js +30 -0
- package/cli/lib/commands/config/reset.js +73 -0
- package/cli/lib/commands/config/show.js +86 -0
- package/cli/lib/commands/device/add.js +143 -0
- package/cli/lib/commands/device/list.js +55 -0
- package/cli/lib/commands/device/remove.js +83 -0
- package/cli/lib/commands/doctor.js +422 -0
- package/cli/lib/commands/logs.js +186 -0
- package/cli/lib/commands/restore.js +172 -0
- package/cli/lib/commands/setup.js +1246 -0
- package/cli/lib/commands/start.js +346 -0
- package/cli/lib/commands/status.js +139 -0
- package/cli/lib/commands/stop.js +101 -0
- package/cli/lib/commands/uninstall.js +183 -0
- package/cli/lib/commands/update.js +205 -0
- package/cli/lib/config.js +113 -0
- package/cli/lib/docker.js +384 -0
- package/cli/lib/mcp-register.js +108 -0
- package/cli/lib/network.js +109 -0
- package/cli/lib/platform.js +85 -0
- package/cli/lib/port-check.js +135 -0
- package/cli/lib/prereqs/checks/compose.js +147 -0
- package/cli/lib/prereqs/checks/disk.js +89 -0
- package/cli/lib/prereqs/checks/docker.js +155 -0
- package/cli/lib/prereqs/checks/network.js +78 -0
- package/cli/lib/prereqs/checks/node.js +95 -0
- package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
- package/cli/lib/prereqs/installers/docker.js +229 -0
- package/cli/lib/prereqs/installers/node.js +254 -0
- package/cli/lib/prereqs/platform.js +175 -0
- package/cli/lib/prereqs/utils/execute.js +208 -0
- package/cli/lib/prereqs/utils/rollback.js +223 -0
- package/cli/lib/prereqs/utils/sudo.js +177 -0
- package/cli/lib/prereqs.js +228 -0
- package/cli/lib/prerequisites.js +115 -0
- package/cli/lib/process-manager.js +176 -0
- package/cli/lib/utils.js +78 -0
- package/cli/lib/validators.js +219 -0
- package/cli/lib/voice-downloader.js +79 -0
- package/docker/entrypoint.sh +110 -0
- package/docker/supervisord.conf +78 -0
- package/docker-compose.yml +40 -0
- package/docs/CLAUDE-CODE-SKILL.md +45 -0
- package/docs/LANGUAGES.md +113 -0
- package/docs/MCP-SERVER.md +111 -0
- package/docs/TROUBLESHOOTING.md +288 -0
- package/mcp-server/index.js +159 -0
- package/mcp-server/package-lock.json +1201 -0
- package/mcp-server/package.json +9 -0
- package/package.json +73 -0
- package/stt-local/Dockerfile +12 -0
- package/stt-local/server.py +45 -0
- package/tts-local/Dockerfile +9 -0
- package/tts-local/server.py +83 -0
- package/voice-app/API-QUERY-CONTRACT.md +238 -0
- package/voice-app/DEPLOYMENT.md +176 -0
- package/voice-app/Dockerfile +20 -0
- package/voice-app/README-OUTBOUND.md +316 -0
- package/voice-app/config/devices.json.example +18 -0
- package/voice-app/index.js +273 -0
- package/voice-app/lib/audio-fork.js +465 -0
- package/voice-app/lib/claude-bridge.js +113 -0
- package/voice-app/lib/connection-retry.js +58 -0
- package/voice-app/lib/conversation-loop.js +490 -0
- package/voice-app/lib/device-registry.js +171 -0
- package/voice-app/lib/http-server.js +242 -0
- package/voice-app/lib/logger.js +35 -0
- package/voice-app/lib/multi-registrar.js +133 -0
- package/voice-app/lib/outbound-handler.js +299 -0
- package/voice-app/lib/outbound-routes.js +417 -0
- package/voice-app/lib/outbound-session.js +324 -0
- package/voice-app/lib/query-routes.js +484 -0
- package/voice-app/lib/registrar.js +144 -0
- package/voice-app/lib/sip-handler.js +490 -0
- package/voice-app/lib/tts-service.js +205 -0
- package/voice-app/lib/whisper-client.js +140 -0
- package/voice-app/package.json +38 -0
- package/voice-app/static/gotit-beep.wav +0 -0
- package/voice-app/static/hold-music.wav +0 -0
- package/voice-app/static/ready-beep.wav +0 -0
- package/voice-app/test/freeswitch-retry.test.js +100 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if Docker is installed and accessible
|
|
8
|
+
* @returns {Promise<object>} Prerequisite check result
|
|
9
|
+
* @property {boolean} installed - True if Docker is available
|
|
10
|
+
* @property {string} [version] - Docker version if installed
|
|
11
|
+
* @property {string} [error] - Error message if check failed
|
|
12
|
+
* @property {string} [installUrl] - Installation URL if not installed
|
|
13
|
+
*/
|
|
14
|
+
export async function checkDocker() {
|
|
15
|
+
try {
|
|
16
|
+
// Check if docker command exists
|
|
17
|
+
const { stdout: whichOutput } = await execAsync('which docker');
|
|
18
|
+
|
|
19
|
+
if (!whichOutput.trim()) {
|
|
20
|
+
return {
|
|
21
|
+
installed: false,
|
|
22
|
+
installUrl: 'https://docs.docker.com/engine/install/'
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get Docker version
|
|
27
|
+
const { stdout: versionOutput } = await execAsync('docker --version');
|
|
28
|
+
const version = versionOutput.trim();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
installed: true,
|
|
32
|
+
version: version
|
|
33
|
+
};
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// Docker not found or not executable
|
|
36
|
+
return {
|
|
37
|
+
installed: false,
|
|
38
|
+
error: error.message,
|
|
39
|
+
installUrl: 'https://docs.docker.com/engine/install/'
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if docker-compose or docker compose is available
|
|
46
|
+
* @returns {Promise<object>} Prerequisite check result
|
|
47
|
+
* @property {boolean} installed - True if compose is available
|
|
48
|
+
* @property {string} [version] - Compose version if installed
|
|
49
|
+
* @property {string} [method] - 'plugin' or 'standalone'
|
|
50
|
+
* @property {string} [error] - Error message if check failed
|
|
51
|
+
* @property {string} [installUrl] - Installation URL if not installed
|
|
52
|
+
*/
|
|
53
|
+
export async function checkDockerCompose() {
|
|
54
|
+
// Try docker compose (plugin) first
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execAsync('docker compose version');
|
|
57
|
+
const version = stdout.trim();
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
installed: true,
|
|
61
|
+
version: version,
|
|
62
|
+
method: 'plugin'
|
|
63
|
+
};
|
|
64
|
+
} catch (pluginError) {
|
|
65
|
+
// Plugin not found, try standalone docker-compose
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await execAsync('docker-compose --version');
|
|
68
|
+
const version = stdout.trim();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
installed: true,
|
|
72
|
+
version: version,
|
|
73
|
+
method: 'standalone'
|
|
74
|
+
};
|
|
75
|
+
} catch (standaloneError) {
|
|
76
|
+
// Neither available
|
|
77
|
+
return {
|
|
78
|
+
installed: false,
|
|
79
|
+
error: 'Neither docker compose nor docker-compose found',
|
|
80
|
+
installUrl: 'https://docs.docker.com/compose/install/'
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check all prerequisites for Raspberry Pi deployment
|
|
88
|
+
* @returns {Promise<Array>} Array of prerequisite check results
|
|
89
|
+
*/
|
|
90
|
+
export async function checkPiPrerequisites() {
|
|
91
|
+
const checks = [];
|
|
92
|
+
|
|
93
|
+
// Check Docker
|
|
94
|
+
const dockerResult = await checkDocker();
|
|
95
|
+
checks.push({
|
|
96
|
+
name: 'Docker',
|
|
97
|
+
installed: dockerResult.installed,
|
|
98
|
+
version: dockerResult.version,
|
|
99
|
+
error: dockerResult.error,
|
|
100
|
+
installUrl: dockerResult.installUrl
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Check Docker Compose
|
|
104
|
+
const composeResult = await checkDockerCompose();
|
|
105
|
+
checks.push({
|
|
106
|
+
name: 'Docker Compose',
|
|
107
|
+
installed: composeResult.installed,
|
|
108
|
+
version: composeResult.version,
|
|
109
|
+
method: composeResult.method,
|
|
110
|
+
error: composeResult.error,
|
|
111
|
+
installUrl: composeResult.installUrl
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return checks;
|
|
115
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getPidPath, getConfigDir } from './config.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the server PID from the PID file
|
|
8
|
+
* @param {string} [pidPath] - Optional PID file path (for testing)
|
|
9
|
+
* @returns {number|null} PID or null if not found
|
|
10
|
+
*/
|
|
11
|
+
export function getServerPid(pidPath = null) {
|
|
12
|
+
pidPath = pidPath || getPidPath();
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(pidPath)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const pidStr = fs.readFileSync(pidPath, 'utf8').trim();
|
|
20
|
+
return parseInt(pidStr, 10);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a process is running
|
|
28
|
+
* @param {number} pid - Process ID
|
|
29
|
+
* @returns {boolean} True if running
|
|
30
|
+
*/
|
|
31
|
+
function isProcessRunning(pid) {
|
|
32
|
+
try {
|
|
33
|
+
// Sending signal 0 checks if process exists without killing it
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if the server is running
|
|
43
|
+
* @param {string} [pidPath] - Optional PID file path (for testing)
|
|
44
|
+
* @returns {Promise<boolean>} True if running
|
|
45
|
+
*/
|
|
46
|
+
export async function isServerRunning(pidPath = null) {
|
|
47
|
+
const pid = getServerPid(pidPath);
|
|
48
|
+
|
|
49
|
+
if (!pid) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return isProcessRunning(pid);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start the claude-api-server
|
|
58
|
+
* @param {string} serverPath - Path to claude-api-server directory
|
|
59
|
+
* @param {number} port - Port to listen on
|
|
60
|
+
* @param {string} [pidPath] - Optional PID file path (for testing)
|
|
61
|
+
* @returns {Promise<number>} Process PID
|
|
62
|
+
*/
|
|
63
|
+
export async function startServer(serverPath, port, pidPath = null) {
|
|
64
|
+
pidPath = pidPath || getPidPath();
|
|
65
|
+
|
|
66
|
+
// Check if already running
|
|
67
|
+
if (await isServerRunning(pidPath)) {
|
|
68
|
+
const pid = getServerPid(pidPath);
|
|
69
|
+
throw new Error(`Server already running (PID: ${pid})`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
// Spawn detached process
|
|
74
|
+
const child = spawn('node', ['server.js'], {
|
|
75
|
+
cwd: serverPath,
|
|
76
|
+
detached: true,
|
|
77
|
+
stdio: 'ignore',
|
|
78
|
+
env: {
|
|
79
|
+
...process.env,
|
|
80
|
+
PORT: port
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Don't wait for child process
|
|
85
|
+
child.unref();
|
|
86
|
+
|
|
87
|
+
// Write PID file
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(pidPath, child.pid.toString(), { mode: 0o600 });
|
|
90
|
+
resolve(child.pid);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Kill the child if we can't write PID file
|
|
93
|
+
try {
|
|
94
|
+
process.kill(child.pid, 'SIGTERM');
|
|
95
|
+
} catch (killError) {
|
|
96
|
+
// Ignore kill errors
|
|
97
|
+
}
|
|
98
|
+
reject(new Error(`Failed to write PID file: ${error.message}`));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stop the claude-api-server
|
|
105
|
+
* @param {string} [pidPath] - Optional PID file path (for testing)
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
export async function stopServer(pidPath = null) {
|
|
109
|
+
pidPath = pidPath || getPidPath();
|
|
110
|
+
const pid = getServerPid(pidPath);
|
|
111
|
+
|
|
112
|
+
if (!pid) {
|
|
113
|
+
// Remove stale PID file if it exists
|
|
114
|
+
if (fs.existsSync(pidPath)) {
|
|
115
|
+
fs.unlinkSync(pidPath);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isProcessRunning(pid)) {
|
|
121
|
+
// Process not running, remove PID file
|
|
122
|
+
fs.unlinkSync(pidPath);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Try graceful shutdown first
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, 'SIGTERM');
|
|
129
|
+
|
|
130
|
+
// Wait up to 5 seconds for graceful shutdown
|
|
131
|
+
const startTime = Date.now();
|
|
132
|
+
while (isProcessRunning(pid) && (Date.now() - startTime) < 5000) {
|
|
133
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Force kill if still running
|
|
137
|
+
if (isProcessRunning(pid)) {
|
|
138
|
+
process.kill(pid, 'SIGKILL');
|
|
139
|
+
// Wait a bit more
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// Process might already be dead
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Remove PID file
|
|
147
|
+
if (fs.existsSync(pidPath)) {
|
|
148
|
+
fs.unlinkSync(pidPath);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Save a PID file for a named service
|
|
154
|
+
* @param {string} name - Service name (e.g., 'claude-api-server')
|
|
155
|
+
* @param {number} pid - Process ID
|
|
156
|
+
*/
|
|
157
|
+
export function savePid(name, pid) {
|
|
158
|
+
const configDir = getConfigDir();
|
|
159
|
+
if (!fs.existsSync(configDir)) {
|
|
160
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
161
|
+
}
|
|
162
|
+
const pidFile = path.join(configDir, `${name}.pid`);
|
|
163
|
+
fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Remove a PID file for a named service
|
|
168
|
+
* @param {string} name - Service name (e.g., 'claude-api-server')
|
|
169
|
+
*/
|
|
170
|
+
export function removePid(name) {
|
|
171
|
+
const configDir = getConfigDir();
|
|
172
|
+
const pidFile = path.join(configDir, `${name}.pid`);
|
|
173
|
+
if (fs.existsSync(pidFile)) {
|
|
174
|
+
fs.unlinkSync(pidFile);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/cli/lib/utils.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the project root directory (where voice-app and claude-api-server live)
|
|
11
|
+
* This resolves from the CLI package location, not process.cwd()
|
|
12
|
+
*
|
|
13
|
+
* Directory structure:
|
|
14
|
+
* claude-phone/ <- project root (returned)
|
|
15
|
+
* ├── cli/
|
|
16
|
+
* │ └── lib/
|
|
17
|
+
* │ └── utils.js <- this file
|
|
18
|
+
* ├── voice-app/
|
|
19
|
+
* └── claude-api-server/
|
|
20
|
+
*
|
|
21
|
+
* @returns {string} Absolute path to project root
|
|
22
|
+
*/
|
|
23
|
+
export function getProjectRoot() {
|
|
24
|
+
// utils.js is at cli/lib/utils.js
|
|
25
|
+
// Project root is two levels up: cli/lib -> cli -> project root
|
|
26
|
+
return path.resolve(__dirname, '..', '..');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get local IP address (best guess)
|
|
31
|
+
* @returns {string} Local IP address or 'auto'
|
|
32
|
+
*/
|
|
33
|
+
export function getLocalIP() {
|
|
34
|
+
const interfaces = os.networkInterfaces();
|
|
35
|
+
|
|
36
|
+
// Look for non-internal IPv4 addresses
|
|
37
|
+
for (const name of Object.keys(interfaces)) {
|
|
38
|
+
for (const iface of interfaces[name]) {
|
|
39
|
+
// Skip internal (loopback) and IPv6
|
|
40
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
41
|
+
return iface.address;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 'auto';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if Claude CLI is installed
|
|
51
|
+
* @returns {Promise<boolean>}
|
|
52
|
+
*/
|
|
53
|
+
export async function isClaudeInstalled() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const check = spawn('which', ['claude']);
|
|
56
|
+
check.on('close', (code) => {
|
|
57
|
+
resolve(code === 0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sleep for specified milliseconds
|
|
64
|
+
* @param {number} ms - Milliseconds to sleep
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
export function sleep(ms) {
|
|
68
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format device config for display
|
|
73
|
+
* @param {object} device - Device configuration
|
|
74
|
+
* @returns {string} Formatted device info
|
|
75
|
+
*/
|
|
76
|
+
export function formatDevice(device) {
|
|
77
|
+
return `${device.name} (ext ${device.extension})`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate ElevenLabs API key by making a test request
|
|
5
|
+
* @param {string} apiKey - ElevenLabs API key
|
|
6
|
+
* @returns {Promise<{valid: boolean, error?: string}>} Validation result
|
|
7
|
+
*/
|
|
8
|
+
export async function validateElevenLabsKey(apiKey) {
|
|
9
|
+
if (!apiKey || apiKey.trim() === '') {
|
|
10
|
+
return {
|
|
11
|
+
valid: false,
|
|
12
|
+
error: 'API key cannot be empty'
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const response = await axios.get('https://api.elevenlabs.io/v1/voices', {
|
|
18
|
+
headers: {
|
|
19
|
+
'xi-api-key': apiKey
|
|
20
|
+
},
|
|
21
|
+
timeout: 10000
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (response.status === 200) {
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
error: `Unexpected status: ${response.status}`
|
|
31
|
+
};
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.response) {
|
|
34
|
+
if (error.response.status === 401) {
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
error: 'Invalid API key (401 Unauthorized)'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
valid: false,
|
|
42
|
+
error: `API error: ${error.response.status} ${error.response.statusText}`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (error.code === 'ECONNABORTED') {
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
error: 'Request timeout - check your internet connection'
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
error: `Network error: ${error.message}`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate OpenAI API key by making a test request
|
|
62
|
+
* @param {string} apiKey - OpenAI API key
|
|
63
|
+
* @returns {Promise<{valid: boolean, error?: string}>} Validation result
|
|
64
|
+
*/
|
|
65
|
+
export async function validateOpenAIKey(apiKey) {
|
|
66
|
+
if (!apiKey || apiKey.trim() === '') {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
error: 'API key cannot be empty'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const response = await axios.get('https://api.openai.com/v1/models', {
|
|
75
|
+
headers: {
|
|
76
|
+
'Authorization': `Bearer ${apiKey}`
|
|
77
|
+
},
|
|
78
|
+
timeout: 10000
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (response.status === 200) {
|
|
82
|
+
return { valid: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
error: `Unexpected status: ${response.status}`
|
|
88
|
+
};
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error.response) {
|
|
91
|
+
if (error.response.status === 401) {
|
|
92
|
+
return {
|
|
93
|
+
valid: false,
|
|
94
|
+
error: 'Invalid API key (401 Unauthorized)'
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
valid: false,
|
|
99
|
+
error: `API error: ${error.response.status} ${error.response.statusText}`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (error.code === 'ECONNABORTED') {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
error: 'Request timeout - check your internet connection'
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
valid: false,
|
|
112
|
+
error: `Network error: ${error.message}`
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate SIP extension format
|
|
119
|
+
* @param {string} extension - SIP extension number
|
|
120
|
+
* @returns {boolean} True if valid
|
|
121
|
+
*/
|
|
122
|
+
export function validateExtension(extension) {
|
|
123
|
+
return /^\d{4,5}$/.test(extension);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate IP address format
|
|
128
|
+
* @param {string} ip - IP address
|
|
129
|
+
* @returns {boolean} True if valid
|
|
130
|
+
*/
|
|
131
|
+
export function validateIP(ip) {
|
|
132
|
+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
133
|
+
if (!ipv4Regex.test(ip)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parts = ip.split('.');
|
|
138
|
+
return parts.every(part => {
|
|
139
|
+
const num = parseInt(part, 10);
|
|
140
|
+
return num >= 0 && num <= 255;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate hostname format
|
|
146
|
+
* @param {string} hostname - Hostname or FQDN
|
|
147
|
+
* @returns {boolean} True if valid
|
|
148
|
+
*/
|
|
149
|
+
export function validateHostname(hostname) {
|
|
150
|
+
const hostnameRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
|
151
|
+
return hostnameRegex.test(hostname);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate ElevenLabs voice ID
|
|
156
|
+
* @param {string} apiKey - ElevenLabs API key
|
|
157
|
+
* @param {string} voiceId - Voice ID to validate
|
|
158
|
+
* @returns {Promise<{valid: boolean, name?: string, error?: string}>} Validation result
|
|
159
|
+
*/
|
|
160
|
+
export async function validateVoiceId(apiKey, voiceId) {
|
|
161
|
+
if (!voiceId || voiceId.trim() === '') {
|
|
162
|
+
return {
|
|
163
|
+
valid: false,
|
|
164
|
+
error: 'Voice ID cannot be empty'
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const response = await axios.get(`https://api.elevenlabs.io/v1/voices/${voiceId}`, {
|
|
170
|
+
headers: {
|
|
171
|
+
'xi-api-key': apiKey
|
|
172
|
+
},
|
|
173
|
+
timeout: 10000
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (response.status === 200 && response.data.name) {
|
|
177
|
+
return {
|
|
178
|
+
valid: true,
|
|
179
|
+
name: response.data.name
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
valid: false,
|
|
185
|
+
error: `Unexpected response: ${response.status}`
|
|
186
|
+
};
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error.response) {
|
|
189
|
+
if (error.response.status === 404) {
|
|
190
|
+
return {
|
|
191
|
+
valid: false,
|
|
192
|
+
error: 'Voice ID not found'
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (error.response.status === 401) {
|
|
196
|
+
return {
|
|
197
|
+
valid: false,
|
|
198
|
+
error: 'Invalid API key (cannot validate voice ID)'
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
valid: false,
|
|
203
|
+
error: `API error: ${error.response.status} ${error.response.statusText}`
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (error.code === 'ECONNABORTED') {
|
|
208
|
+
return {
|
|
209
|
+
valid: false,
|
|
210
|
+
error: 'Request timeout - check your internet connection'
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
valid: false,
|
|
216
|
+
error: `Network error: ${error.message}`
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
|
|
5
|
+
// Curated list of common Piper voices with their known-good download URLs.
|
|
6
|
+
// If a user picks one of these during setup, we fetch it automatically.
|
|
7
|
+
// Anyone who wants a different voice from the full catalog at
|
|
8
|
+
// huggingface.co/rhasspy/piper-voices can still drop files into
|
|
9
|
+
// <ttsLocal>/voices/ manually — that path stays supported.
|
|
10
|
+
const KNOWN_VOICES = {
|
|
11
|
+
'en_US-lessac-medium': 'en/en_US/lessac/medium',
|
|
12
|
+
'en_US-amy-medium': 'en/en_US/amy/medium',
|
|
13
|
+
'en_US-ryan-high': 'en/en_US/ryan/high',
|
|
14
|
+
'en_GB-alan-medium': 'en/en_GB/alan/medium'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const BASE_URL = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
|
|
18
|
+
|
|
19
|
+
function downloadFile(url, destPath) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const file = fs.createWriteStream(destPath);
|
|
22
|
+
https.get(url, (response) => {
|
|
23
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
24
|
+
// follow redirect
|
|
25
|
+
https.get(response.headers.location, (redirected) => {
|
|
26
|
+
redirected.pipe(file);
|
|
27
|
+
file.on('finish', () => file.close(resolve));
|
|
28
|
+
}).on('error', reject);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (response.statusCode !== 200) {
|
|
32
|
+
reject(new Error(`HTTP ${response.statusCode} for ${url}`));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
response.pipe(file);
|
|
36
|
+
file.on('finish', () => file.close(resolve));
|
|
37
|
+
}).on('error', (err) => {
|
|
38
|
+
fs.unlink(destPath, () => {});
|
|
39
|
+
reject(err);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensure a Piper voice model is present locally, downloading it if it's a
|
|
46
|
+
* known voice and not already there. Silently does nothing for unknown
|
|
47
|
+
* voice names (those are assumed to be manually placed by the user).
|
|
48
|
+
* @param {string} voiceName - e.g. "en_US-lessac-medium"
|
|
49
|
+
* @param {string} voicesDir - path to tts-local/voices
|
|
50
|
+
* @returns {Promise<{downloaded: boolean, skipped: boolean, error?: string}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureVoiceModel(voiceName, voicesDir) {
|
|
53
|
+
const modelPath = path.join(voicesDir, `${voiceName}.onnx`);
|
|
54
|
+
const configPath = path.join(voicesDir, `${voiceName}.onnx.json`);
|
|
55
|
+
|
|
56
|
+
if (fs.existsSync(modelPath) && fs.existsSync(configPath)) {
|
|
57
|
+
return { downloaded: false, skipped: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const subPath = KNOWN_VOICES[voiceName];
|
|
61
|
+
if (!subPath) {
|
|
62
|
+
return {
|
|
63
|
+
downloaded: false,
|
|
64
|
+
skipped: false,
|
|
65
|
+
error: `Unknown voice "${voiceName}" — not in the auto-download list. ` +
|
|
66
|
+
`Download it manually from huggingface.co/rhasspy/piper-voices ` +
|
|
67
|
+
`into ${voicesDir}/`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fs.mkdirSync(voicesDir, { recursive: true });
|
|
72
|
+
|
|
73
|
+
await downloadFile(`${BASE_URL}/${subPath}/${voiceName}.onnx`, modelPath);
|
|
74
|
+
await downloadFile(`${BASE_URL}/${subPath}/${voiceName}.onnx.json`, configPath);
|
|
75
|
+
|
|
76
|
+
return { downloaded: true, skipped: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { KNOWN_VOICES };
|