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,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Server for TTS Audio Delivery
|
|
3
|
+
*
|
|
4
|
+
* Express server that:
|
|
5
|
+
* 1. Serves generated TTS audio files to FreeSWITCH
|
|
6
|
+
* 2. Provides health check endpoint
|
|
7
|
+
* 3. Accepts audio uploads and returns playback URLs
|
|
8
|
+
* 4. Automatically cleans up old temporary files
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const express = require('express');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs').promises;
|
|
14
|
+
const debug = require('debug')('voice-app:http-server');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// Cleanup interval: every 2 minutes
|
|
18
|
+
const CLEANUP_INTERVAL = 120000;
|
|
19
|
+
// File max age: 10 minutes
|
|
20
|
+
const FILE_MAX_AGE = 600000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create HTTP Server
|
|
24
|
+
*
|
|
25
|
+
* @param {string} audioDir - Directory to serve audio files from
|
|
26
|
+
* @param {number} port - Port to listen on (default: 3000)
|
|
27
|
+
* @returns {Object} { app, server, saveAudio, getAudioUrl, close, finalize }
|
|
28
|
+
*/
|
|
29
|
+
function createHttpServer(audioDir, port = 3000) {
|
|
30
|
+
const app = express();
|
|
31
|
+
|
|
32
|
+
// Parse JSON bodies
|
|
33
|
+
app.use(express.json());
|
|
34
|
+
|
|
35
|
+
// Parse binary bodies for audio upload
|
|
36
|
+
app.use('/audio', express.raw({ type: 'audio/*', limit: '10mb' }));
|
|
37
|
+
|
|
38
|
+
// Serve static audio files
|
|
39
|
+
app.use('/audio-files', express.static(audioDir, {
|
|
40
|
+
setHeaders: (res, filepath) => {
|
|
41
|
+
// Set appropriate content type for audio files
|
|
42
|
+
if (filepath.endsWith('.wav')) {
|
|
43
|
+
res.setHeader('Content-Type', 'audio/wav');
|
|
44
|
+
} else if (filepath.endsWith('.mp3')) {
|
|
45
|
+
res.setHeader('Content-Type', 'audio/mpeg');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// Serve STATIC audio files (beeps, hold music) - NOT subject to cleanup
|
|
51
|
+
app.use('/static', express.static(path.join(__dirname, '..', 'static'), {
|
|
52
|
+
setHeaders: (res, filepath) => {
|
|
53
|
+
if (filepath.endsWith('.wav')) {
|
|
54
|
+
res.setHeader('Content-Type', 'audio/wav');
|
|
55
|
+
} else if (filepath.endsWith('.mp3')) {
|
|
56
|
+
res.setHeader('Content-Type', 'audio/mpeg');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
// Health check endpoint
|
|
62
|
+
app.get('/health', (req, res) => {
|
|
63
|
+
res.json({
|
|
64
|
+
status: 'healthy',
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
audioDir,
|
|
67
|
+
port
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Audio upload endpoint
|
|
72
|
+
app.post("/audio", async (req, res) => {
|
|
73
|
+
try {
|
|
74
|
+
const audioBuffer = req.body;
|
|
75
|
+
|
|
76
|
+
if (!audioBuffer || audioBuffer.length === 0) {
|
|
77
|
+
return res.status(400).json({
|
|
78
|
+
error: 'No audio data provided'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Generate unique filename
|
|
83
|
+
const filename = `audio_${Date.now()}_${crypto.randomBytes(8).toString('hex')}.wav`;
|
|
84
|
+
const filepath = path.join(audioDir, filename);
|
|
85
|
+
|
|
86
|
+
debug(`Saving audio to ${filepath} (${audioBuffer.length} bytes)`);
|
|
87
|
+
|
|
88
|
+
// Save to disk
|
|
89
|
+
await fs.writeFile(filepath, audioBuffer);
|
|
90
|
+
|
|
91
|
+
// Generate URL
|
|
92
|
+
const url = `http://localhost:${port}/audio-files/${filename}`;
|
|
93
|
+
|
|
94
|
+
debug(`Audio saved, URL: ${url}`);
|
|
95
|
+
|
|
96
|
+
res.json({
|
|
97
|
+
success: true,
|
|
98
|
+
url,
|
|
99
|
+
filename,
|
|
100
|
+
size: audioBuffer.length
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('Error saving audio:', error);
|
|
105
|
+
res.status(500).json({
|
|
106
|
+
error: 'Failed to save audio',
|
|
107
|
+
message: error.message
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// NOTE: 404 and error handlers are added in finalize() AFTER additional routes
|
|
113
|
+
|
|
114
|
+
// Start server
|
|
115
|
+
const server = app.listen(port, () => {
|
|
116
|
+
debug(`HTTP server listening on port ${port}`);
|
|
117
|
+
debug(`Serving audio files from ${audioDir}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Cleanup old files periodically
|
|
121
|
+
const cleanupTimer = setInterval(async () => {
|
|
122
|
+
try {
|
|
123
|
+
await cleanupOldFiles(audioDir, FILE_MAX_AGE);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Error during cleanup:', error);
|
|
126
|
+
}
|
|
127
|
+
}, CLEANUP_INTERVAL);
|
|
128
|
+
|
|
129
|
+
// Cleanup on server close
|
|
130
|
+
const originalClose = server.close.bind(server);
|
|
131
|
+
server.close = (callback) => {
|
|
132
|
+
debug('Stopping HTTP server');
|
|
133
|
+
clearInterval(cleanupTimer);
|
|
134
|
+
originalClose(callback);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Save audio buffer to file and return URL
|
|
139
|
+
* @param {Buffer} audioBuffer - Audio data
|
|
140
|
+
* @param {string} format - File format (wav, mp3)
|
|
141
|
+
* @returns {Promise<string>} URL to audio file
|
|
142
|
+
*/
|
|
143
|
+
async function saveAudio(audioBuffer, format = 'wav') {
|
|
144
|
+
const filename = `audio_${Date.now()}_${crypto.randomBytes(8).toString('hex')}.${format}`;
|
|
145
|
+
const filepath = path.join(audioDir, filename);
|
|
146
|
+
|
|
147
|
+
debug(`Saving ${format} audio to ${filepath} (${audioBuffer.length} bytes)`);
|
|
148
|
+
|
|
149
|
+
await fs.writeFile(filepath, audioBuffer);
|
|
150
|
+
|
|
151
|
+
const url = `http://localhost:${port}/audio-files/${filename}`;
|
|
152
|
+
debug(`Audio saved, URL: ${url}`);
|
|
153
|
+
|
|
154
|
+
return url;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get URL for a filename in audio directory
|
|
159
|
+
* @param {string} filename - Name of audio file
|
|
160
|
+
* @returns {string} Full URL
|
|
161
|
+
*/
|
|
162
|
+
function getAudioUrl(filename) {
|
|
163
|
+
return `http://localhost:${port}/audio-files/${filename}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Finalize the Express app by adding 404 and error handlers
|
|
168
|
+
* Call this AFTER adding any additional routes
|
|
169
|
+
*/
|
|
170
|
+
function finalize() {
|
|
171
|
+
// 404 handler
|
|
172
|
+
app.use((req, res) => {
|
|
173
|
+
res.status(404).json({
|
|
174
|
+
error: 'Not found',
|
|
175
|
+
path: req.path
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Error handler
|
|
180
|
+
app.use((err, req, res, next) => {
|
|
181
|
+
console.error('Server error:', err);
|
|
182
|
+
res.status(500).json({
|
|
183
|
+
error: 'Internal server error',
|
|
184
|
+
message: err.message
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
debug('HTTP server finalized with 404/error handlers');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
app,
|
|
193
|
+
server,
|
|
194
|
+
saveAudio,
|
|
195
|
+
getAudioUrl,
|
|
196
|
+
close: () => server.close(),
|
|
197
|
+
finalize
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Cleanup files older than maxAge
|
|
203
|
+
* @param {string} directory - Directory to clean
|
|
204
|
+
* @param {number} maxAge - Max age in milliseconds
|
|
205
|
+
*/
|
|
206
|
+
async function cleanupOldFiles(directory, maxAge) {
|
|
207
|
+
try {
|
|
208
|
+
const files = await fs.readdir(directory);
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
let deletedCount = 0;
|
|
211
|
+
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const filepath = path.join(directory, file);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const stats = await fs.stat(filepath);
|
|
217
|
+
const age = now - stats.mtimeMs;
|
|
218
|
+
|
|
219
|
+
if (age > maxAge) {
|
|
220
|
+
debug(`Deleting old file: ${file} (age: ${Math.round(age / 1000)}s)`);
|
|
221
|
+
await fs.unlink(filepath);
|
|
222
|
+
deletedCount++;
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Skip files that can't be accessed
|
|
226
|
+
debug(`Error checking file ${file}:`, error.message);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (deletedCount > 0) {
|
|
231
|
+
debug(`Cleanup complete: deleted ${deletedCount} old files`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error('Error during cleanup:', error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
createHttpServer,
|
|
241
|
+
cleanupOldFiles
|
|
242
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple logger module
|
|
3
|
+
* Provides consistent logging format with timestamps
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function formatMessage(level, message, data = {}) {
|
|
7
|
+
const timestamp = new Date().toISOString();
|
|
8
|
+
const dataStr = Object.keys(data).length > 0 ? ' ' + JSON.stringify(data) : '';
|
|
9
|
+
return `[${timestamp}] ${level.toUpperCase()} ${message}${dataStr}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function info(message, data) {
|
|
13
|
+
console.log(formatMessage('info', message, data));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function warn(message, data) {
|
|
17
|
+
console.warn(formatMessage('warn', message, data));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function error(message, data) {
|
|
21
|
+
console.error(formatMessage('error', message, data));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function debug(message, data) {
|
|
25
|
+
if (process.env.DEBUG) {
|
|
26
|
+
console.log(formatMessage('debug', message, data));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
info,
|
|
32
|
+
warn,
|
|
33
|
+
error,
|
|
34
|
+
debug
|
|
35
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Extension SIP Registrar
|
|
3
|
+
* Registers multiple extensions with 3CX independently
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class MultiRegistrar {
|
|
7
|
+
constructor(srf, baseConfig) {
|
|
8
|
+
this.srf = srf;
|
|
9
|
+
this.baseConfig = baseConfig;
|
|
10
|
+
this.registrations = new Map();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register all devices from config object
|
|
15
|
+
* @param {Object} devices - Object keyed by extension with device configs
|
|
16
|
+
*/
|
|
17
|
+
registerAll(devices) {
|
|
18
|
+
const extensions = Object.keys(devices);
|
|
19
|
+
console.log('[MULTI-REGISTRAR] Starting registration for ' + extensions.length + ' devices');
|
|
20
|
+
|
|
21
|
+
for (const [extension, device] of Object.entries(devices)) {
|
|
22
|
+
this.registerDevice(device);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a single device
|
|
28
|
+
*/
|
|
29
|
+
registerDevice(device) {
|
|
30
|
+
const config = {
|
|
31
|
+
extension: device.extension,
|
|
32
|
+
auth_id: device.authId,
|
|
33
|
+
password: device.password,
|
|
34
|
+
domain: this.baseConfig.domain,
|
|
35
|
+
registrar: this.baseConfig.registrar,
|
|
36
|
+
registrar_port: this.baseConfig.registrar_port,
|
|
37
|
+
expiry: this.baseConfig.expiry,
|
|
38
|
+
local_address: this.baseConfig.local_address,
|
|
39
|
+
local_port: this.baseConfig.local_port
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
console.log('[MULTI-REGISTRAR] Registering ' + device.name + ' (ext ' + device.extension + ')');
|
|
43
|
+
this.sendRegister(device, config);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send REGISTER request for a device
|
|
48
|
+
*/
|
|
49
|
+
sendRegister(device, config) {
|
|
50
|
+
const self = this;
|
|
51
|
+
const uri = 'sip:' + config.registrar + ':' + config.registrar_port + ';transport=udp';
|
|
52
|
+
// Include local_port in Contact so INVITEs come to the right port (5070 when SBC is on 5060)
|
|
53
|
+
const localPort = config.local_port || 5060;
|
|
54
|
+
const contact = 'sip:' + config.extension + '@' + config.local_address + ':' + localPort;
|
|
55
|
+
|
|
56
|
+
console.log('[MULTI-REGISTRAR] REGISTER ' + device.name + ' to ' + uri);
|
|
57
|
+
console.log('[MULTI-REGISTRAR] Contact: ' + contact);
|
|
58
|
+
|
|
59
|
+
this.srf.request(uri, {
|
|
60
|
+
method: 'REGISTER',
|
|
61
|
+
headers: {
|
|
62
|
+
'From': '<sip:' + config.extension + '@' + config.domain + '>',
|
|
63
|
+
'To': '<sip:' + config.extension + '@' + config.domain + '>',
|
|
64
|
+
'Contact': '<' + contact + '>;expires=' + config.expiry,
|
|
65
|
+
'Expires': config.expiry,
|
|
66
|
+
'User-Agent': 'ClaudePhoneLocal-VoiceServer/1.0'
|
|
67
|
+
},
|
|
68
|
+
auth: {
|
|
69
|
+
username: config.auth_id,
|
|
70
|
+
password: config.password
|
|
71
|
+
}
|
|
72
|
+
}, function(err, req) {
|
|
73
|
+
if (err) {
|
|
74
|
+
console.error('[MULTI-REGISTRAR] ' + device.name + ' request error: ' + err.message);
|
|
75
|
+
self.scheduleRetry(device, config, 60);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
req.on('response', function(res) {
|
|
80
|
+
if (res.status === 200) {
|
|
81
|
+
console.log('[MULTI-REGISTRAR] ' + device.name + ' SUCCESS - Registered as ext ' + config.extension);
|
|
82
|
+
|
|
83
|
+
var expiry = config.expiry;
|
|
84
|
+
var contactHeader = res.get('Contact');
|
|
85
|
+
if (contactHeader) {
|
|
86
|
+
var match = contactHeader.match(/expires=(\d+)/i);
|
|
87
|
+
if (match) expiry = parseInt(match[1], 10);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
self.registrations.set(config.extension, {
|
|
91
|
+
device: device,
|
|
92
|
+
config: config,
|
|
93
|
+
expiry: expiry,
|
|
94
|
+
registeredAt: Date.now()
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
var refreshTime = Math.floor(expiry * 0.9);
|
|
98
|
+
console.log('[MULTI-REGISTRAR] ' + device.name + ' refresh in ' + refreshTime + 's');
|
|
99
|
+
self.scheduleRefresh(device, config, refreshTime);
|
|
100
|
+
|
|
101
|
+
} else if (res.status === 401 || res.status === 407) {
|
|
102
|
+
console.log('[MULTI-REGISTRAR] ' + device.name + ' auth challenge - handled by drachtio');
|
|
103
|
+
} else {
|
|
104
|
+
console.error('[MULTI-REGISTRAR] ' + device.name + ' FAILED: ' + res.status + ' ' + res.reason);
|
|
105
|
+
self.scheduleRetry(device, config, 60);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
scheduleRefresh(device, config, seconds) {
|
|
112
|
+
const self = this;
|
|
113
|
+
setTimeout(function() {
|
|
114
|
+
console.log('[MULTI-REGISTRAR] Refreshing ' + device.name);
|
|
115
|
+
self.sendRegister(device, config);
|
|
116
|
+
}, seconds * 1000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
scheduleRetry(device, config, seconds) {
|
|
120
|
+
const self = this;
|
|
121
|
+
console.log('[MULTI-REGISTRAR] ' + device.name + ' retry in ' + seconds + 's');
|
|
122
|
+
setTimeout(function() {
|
|
123
|
+
self.sendRegister(device, config);
|
|
124
|
+
}, seconds * 1000);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
stop() {
|
|
128
|
+
this.registrations.clear();
|
|
129
|
+
console.log('[MULTI-REGISTRAR] Stopped all registrations');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = MultiRegistrar;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound Call Handler
|
|
3
|
+
* Core logic for initiating outbound SIP calls via drachtio
|
|
4
|
+
* v2: Added voiceId support for device-specific TTS
|
|
5
|
+
*
|
|
6
|
+
* Uses Early Offer pattern:
|
|
7
|
+
* 1. Create FreeSWITCH endpoint first to get local SDP
|
|
8
|
+
* 2. Send INVITE with our SDP
|
|
9
|
+
* 3. On answer, connect the endpoint with remote SDP
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { v4: uuidv4 } = require('uuid');
|
|
13
|
+
const logger = require('./logger');
|
|
14
|
+
const ttsService = require('./tts-service');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initiate an outbound call
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} srf - drachtio SRF instance
|
|
20
|
+
* @param {Object} mediaServer - FreeSWITCH media server
|
|
21
|
+
* @param {Object} options - Call options
|
|
22
|
+
* @param {string} options.to - Phone number in E.164 format (+15551234567)
|
|
23
|
+
* @param {string} options.message - Message to play when answered
|
|
24
|
+
* @param {string} [options.callerId] - Caller ID (defaults to DEFAULT_CALLER_ID env var)
|
|
25
|
+
* @param {number} [options.timeoutSeconds=30] - Ring timeout in seconds
|
|
26
|
+
* @returns {Promise<Object>} { callId, dialog, endpoint }
|
|
27
|
+
*/
|
|
28
|
+
async function initiateOutboundCall(srf, mediaServer, options) {
|
|
29
|
+
const {
|
|
30
|
+
to,
|
|
31
|
+
message,
|
|
32
|
+
callerId,
|
|
33
|
+
timeoutSeconds = 30,
|
|
34
|
+
deviceConfig = null
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
const callId = uuidv4();
|
|
38
|
+
const startTime = Date.now();
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
logger.info('Initiating outbound call', {
|
|
42
|
+
callId,
|
|
43
|
+
to,
|
|
44
|
+
callerId,
|
|
45
|
+
timeout: timeoutSeconds
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// STEP 1: Create FreeSWITCH endpoint first (Early Offer pattern)
|
|
49
|
+
logger.info('Creating FreeSWITCH endpoint', { callId });
|
|
50
|
+
const endpoint = await mediaServer.createEndpoint();
|
|
51
|
+
|
|
52
|
+
// Get local SDP from FreeSWITCH
|
|
53
|
+
const localSdp = endpoint.local.sdp;
|
|
54
|
+
|
|
55
|
+
// Format SIP URI for 3CX
|
|
56
|
+
// Remove '+' from E.164 format for SIP URI
|
|
57
|
+
// Internal extensions: dial as-is. External (E.164 with +): add 9 prefix for PSTN
|
|
58
|
+
const isExternal = to.startsWith('+');
|
|
59
|
+
// Upstream hardcoded a '9' outdial prefix for their PBX. Most systems do not
|
|
60
|
+
// use one, so it defaults to empty and is configurable.
|
|
61
|
+
const outdialPrefix = process.env.SIP_OUTDIAL_PREFIX || '';
|
|
62
|
+
const phoneNumber = isExternal ? outdialPrefix + to.replace(/^\+1?/, '') : to;
|
|
63
|
+
// Falls back to the registrar (the local SBC) rather than a hardcoded IP from
|
|
64
|
+
// someone else's network, which silently broke every outbound call.
|
|
65
|
+
const sipTrunkHost = process.env.SIP_TRUNK_HOST || process.env.SIP_REGISTRAR || '127.0.0.1';
|
|
66
|
+
const externalIp = process.env.EXTERNAL_IP || '10.70.7.81';
|
|
67
|
+
const defaultCallerId = callerId || process.env.DEFAULT_CALLER_ID || '+15551234567';
|
|
68
|
+
|
|
69
|
+
// SIP Authentication for 3CX extension registration
|
|
70
|
+
// .env defines SIP_AUTH_ID / SIP_PASSWORD; the old names here never matched,
|
|
71
|
+
// so outbound calls went out unauthenticated and 3CX rejected them.
|
|
72
|
+
const sipAuthUsername = process.env.SIP_AUTH_USERNAME || process.env.SIP_AUTH_ID;
|
|
73
|
+
const sipAuthPassword = process.env.SIP_AUTH_PASSWORD || process.env.SIP_PASSWORD;
|
|
74
|
+
|
|
75
|
+
// Without an explicit transport drachtio dials TCP, but the 3CX SBC listens on
|
|
76
|
+
// UDP 5060 - which surfaces as "Connection refused ... tcp/<sbc>:5060" and a 503.
|
|
77
|
+
const trunkTransport = process.env.SIP_TRUNK_TRANSPORT || 'udp';
|
|
78
|
+
const sipUri = 'sip:' + phoneNumber + '@' + sipTrunkHost + ';transport=' + trunkTransport;
|
|
79
|
+
|
|
80
|
+
logger.info('Dialing SIP URI', {
|
|
81
|
+
callId,
|
|
82
|
+
sipUri,
|
|
83
|
+
from: defaultCallerId,
|
|
84
|
+
hasAuth: !!(sipAuthUsername && sipAuthPassword)
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// STEP 2: Create UAC (outbound call) with Early Offer
|
|
88
|
+
// Use device extension and display name if available, otherwise fall back to callerId
|
|
89
|
+
const fromExtension = deviceConfig ? deviceConfig.extension : defaultCallerId.replace('+', '');
|
|
90
|
+
const displayName = deviceConfig ? deviceConfig.name : null;
|
|
91
|
+
const fromHeader = displayName
|
|
92
|
+
? '"' + displayName + '" <sip:' + fromExtension + '@' + sipTrunkHost + '>'
|
|
93
|
+
: '<sip:' + fromExtension + '@' + sipTrunkHost + '>';
|
|
94
|
+
|
|
95
|
+
const uacOptions = {
|
|
96
|
+
localSdp: localSdp,
|
|
97
|
+
headers: {
|
|
98
|
+
'From': fromHeader,
|
|
99
|
+
'User-Agent': 'ClaudePhoneLocal-VoiceServer/1.0',
|
|
100
|
+
'X-Call-ID': callId
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Add SIP authentication - prefer device credentials, fall back to env vars
|
|
105
|
+
const authUsername = deviceConfig ? deviceConfig.authId : sipAuthUsername;
|
|
106
|
+
const authPassword = deviceConfig ? deviceConfig.password : sipAuthPassword;
|
|
107
|
+
|
|
108
|
+
if (authUsername && authPassword) {
|
|
109
|
+
uacOptions.auth = {
|
|
110
|
+
username: authUsername,
|
|
111
|
+
password: authPassword
|
|
112
|
+
};
|
|
113
|
+
logger.info('SIP authentication enabled', {
|
|
114
|
+
callId,
|
|
115
|
+
username: authUsername,
|
|
116
|
+
device: deviceConfig ? deviceConfig.name : 'default'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let isRinging = false;
|
|
121
|
+
let callAnswered = false;
|
|
122
|
+
|
|
123
|
+
// Create the outbound call (returns dialog directly, not { uas, uac })
|
|
124
|
+
const uac = await srf.createUAC(sipUri, uacOptions, {
|
|
125
|
+
cbRequest: function(err, req) {
|
|
126
|
+
// Called when INVITE is sent
|
|
127
|
+
if (err) {
|
|
128
|
+
logger.error('INVITE send failed', { callId, error: err.message });
|
|
129
|
+
} else {
|
|
130
|
+
logger.info('INVITE sent successfully', { callId });
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
cbProvisional: function(res) {
|
|
134
|
+
// Called on provisional responses (180 Ringing, 183 Progress, etc.)
|
|
135
|
+
logger.info('Provisional response received', {
|
|
136
|
+
callId,
|
|
137
|
+
status: res.status,
|
|
138
|
+
reason: res.reason
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (res.status === 180) {
|
|
142
|
+
isRinging = true;
|
|
143
|
+
logger.info('Phone is ringing', { callId, to });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// STEP 3: Call was answered! Connect endpoint with remote SDP
|
|
149
|
+
callAnswered = true;
|
|
150
|
+
const latency = Date.now() - startTime;
|
|
151
|
+
|
|
152
|
+
logger.info('Call answered', {
|
|
153
|
+
callId,
|
|
154
|
+
to,
|
|
155
|
+
latency,
|
|
156
|
+
isRinging
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Modify endpoint with remote SDP to complete media connection
|
|
160
|
+
await endpoint.modify(uac.remote.sdp);
|
|
161
|
+
|
|
162
|
+
logger.info('Media connection established', { callId });
|
|
163
|
+
|
|
164
|
+
// Setup call cleanup on remote hangup
|
|
165
|
+
uac.on('destroy', function() {
|
|
166
|
+
logger.info('Remote party hung up', { callId });
|
|
167
|
+
if (endpoint) {
|
|
168
|
+
endpoint.destroy().catch(function(err) {
|
|
169
|
+
logger.warn('Failed to destroy endpoint on hangup', {
|
|
170
|
+
callId,
|
|
171
|
+
error: err.message
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
callId,
|
|
179
|
+
dialog: uac,
|
|
180
|
+
endpoint,
|
|
181
|
+
isRinging,
|
|
182
|
+
latency
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const latency = Date.now() - startTime;
|
|
187
|
+
|
|
188
|
+
logger.error('Outbound call failed', {
|
|
189
|
+
callId,
|
|
190
|
+
to,
|
|
191
|
+
error: error.message,
|
|
192
|
+
latency
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Handle specific SIP error codes
|
|
196
|
+
if (error.status) {
|
|
197
|
+
const status = error.status;
|
|
198
|
+
if (status === 486) {
|
|
199
|
+
throw new Error('busy');
|
|
200
|
+
} else if (status === 480 || status === 408) {
|
|
201
|
+
throw new Error('no_answer');
|
|
202
|
+
} else if (status === 404) {
|
|
203
|
+
throw new Error('not_found');
|
|
204
|
+
} else if (status === 503) {
|
|
205
|
+
throw new Error('service_unavailable');
|
|
206
|
+
} else if (status === 401 || status === 407) {
|
|
207
|
+
throw new Error('auth_failed');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Play a TTS message to an active call
|
|
217
|
+
*
|
|
218
|
+
* @param {Object} endpoint - FreeSWITCH endpoint
|
|
219
|
+
* @param {string} message - Text to convert to speech and play
|
|
220
|
+
* @param {Object} [options] - Playback options
|
|
221
|
+
* @param {string} [options.voiceId] - ElevenLabs voice ID for device-specific voice
|
|
222
|
+
* @returns {Promise<void>}
|
|
223
|
+
*/
|
|
224
|
+
async function playMessage(endpoint, message, options) {
|
|
225
|
+
options = options || {};
|
|
226
|
+
var voiceId = options.voiceId || null;
|
|
227
|
+
var startTime = Date.now();
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
logger.info('Generating TTS for outbound call', {
|
|
231
|
+
textLength: message.length,
|
|
232
|
+
voiceId: voiceId || 'default'
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Generate TTS audio file with optional device voice
|
|
236
|
+
var audioUrl = await ttsService.generateSpeech(message, voiceId);
|
|
237
|
+
|
|
238
|
+
logger.info('Playing TTS to caller', { audioUrl: audioUrl });
|
|
239
|
+
|
|
240
|
+
// Play the audio file via FreeSWITCH
|
|
241
|
+
await endpoint.play(audioUrl);
|
|
242
|
+
|
|
243
|
+
var duration = Date.now() - startTime;
|
|
244
|
+
|
|
245
|
+
logger.info('TTS playback completed', {
|
|
246
|
+
duration: duration,
|
|
247
|
+
audioUrl: audioUrl
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
} catch (error) {
|
|
251
|
+
logger.error('Failed to play message', {
|
|
252
|
+
error: error.message
|
|
253
|
+
});
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Hangup an active outbound call
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} dialog - drachtio dialog (UAC)
|
|
262
|
+
* @param {Object} endpoint - FreeSWITCH endpoint
|
|
263
|
+
* @param {string} callId - Call UUID for logging
|
|
264
|
+
*/
|
|
265
|
+
async function hangupCall(dialog, endpoint, callId) {
|
|
266
|
+
logger.info('Hanging up outbound call', { callId: callId });
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
// Destroy SIP dialog
|
|
270
|
+
if (dialog && !dialog.destroyed) {
|
|
271
|
+
await dialog.destroy();
|
|
272
|
+
logger.info('Dialog destroyed', { callId: callId });
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
logger.warn('Failed to destroy dialog', {
|
|
276
|
+
callId: callId,
|
|
277
|
+
error: error.message
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
// Destroy FreeSWITCH endpoint
|
|
283
|
+
if (endpoint) {
|
|
284
|
+
await endpoint.destroy();
|
|
285
|
+
logger.info('Endpoint destroyed', { callId: callId });
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
logger.warn('Failed to destroy endpoint', {
|
|
289
|
+
callId: callId,
|
|
290
|
+
error: error.message
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
initiateOutboundCall: initiateOutboundCall,
|
|
297
|
+
playMessage: playMessage,
|
|
298
|
+
hangupCall: hangupCall
|
|
299
|
+
};
|