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.
Files changed (96) hide show
  1. package/.env.example +108 -0
  2. package/Dockerfile +84 -0
  3. package/LICENSE +22 -0
  4. package/README.md +231 -0
  5. package/claude-api-server/package-lock.json +832 -0
  6. package/claude-api-server/package.json +20 -0
  7. package/claude-api-server/server.js +540 -0
  8. package/claude-api-server/structured.js +208 -0
  9. package/cli/README.md +231 -0
  10. package/cli/bin/check-publish-safety.js +53 -0
  11. package/cli/bin/claude-phone.js +23 -0
  12. package/cli/bin/cli-main.js +284 -0
  13. package/cli/bin/postinstall.js +20 -0
  14. package/cli/lib/commands/api-server.js +77 -0
  15. package/cli/lib/commands/backup.js +69 -0
  16. package/cli/lib/commands/config/path.js +30 -0
  17. package/cli/lib/commands/config/reset.js +73 -0
  18. package/cli/lib/commands/config/show.js +86 -0
  19. package/cli/lib/commands/device/add.js +143 -0
  20. package/cli/lib/commands/device/list.js +55 -0
  21. package/cli/lib/commands/device/remove.js +83 -0
  22. package/cli/lib/commands/doctor.js +422 -0
  23. package/cli/lib/commands/logs.js +186 -0
  24. package/cli/lib/commands/restore.js +172 -0
  25. package/cli/lib/commands/setup.js +1246 -0
  26. package/cli/lib/commands/start.js +346 -0
  27. package/cli/lib/commands/status.js +139 -0
  28. package/cli/lib/commands/stop.js +101 -0
  29. package/cli/lib/commands/uninstall.js +183 -0
  30. package/cli/lib/commands/update.js +205 -0
  31. package/cli/lib/config.js +113 -0
  32. package/cli/lib/docker.js +384 -0
  33. package/cli/lib/mcp-register.js +108 -0
  34. package/cli/lib/network.js +109 -0
  35. package/cli/lib/platform.js +85 -0
  36. package/cli/lib/port-check.js +135 -0
  37. package/cli/lib/prereqs/checks/compose.js +147 -0
  38. package/cli/lib/prereqs/checks/disk.js +89 -0
  39. package/cli/lib/prereqs/checks/docker.js +155 -0
  40. package/cli/lib/prereqs/checks/network.js +78 -0
  41. package/cli/lib/prereqs/checks/node.js +95 -0
  42. package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
  43. package/cli/lib/prereqs/installers/docker.js +229 -0
  44. package/cli/lib/prereqs/installers/node.js +254 -0
  45. package/cli/lib/prereqs/platform.js +175 -0
  46. package/cli/lib/prereqs/utils/execute.js +208 -0
  47. package/cli/lib/prereqs/utils/rollback.js +223 -0
  48. package/cli/lib/prereqs/utils/sudo.js +177 -0
  49. package/cli/lib/prereqs.js +228 -0
  50. package/cli/lib/prerequisites.js +115 -0
  51. package/cli/lib/process-manager.js +176 -0
  52. package/cli/lib/utils.js +78 -0
  53. package/cli/lib/validators.js +219 -0
  54. package/cli/lib/voice-downloader.js +79 -0
  55. package/docker/entrypoint.sh +110 -0
  56. package/docker/supervisord.conf +78 -0
  57. package/docker-compose.yml +40 -0
  58. package/docs/CLAUDE-CODE-SKILL.md +45 -0
  59. package/docs/LANGUAGES.md +113 -0
  60. package/docs/MCP-SERVER.md +111 -0
  61. package/docs/TROUBLESHOOTING.md +288 -0
  62. package/mcp-server/index.js +159 -0
  63. package/mcp-server/package-lock.json +1201 -0
  64. package/mcp-server/package.json +9 -0
  65. package/package.json +73 -0
  66. package/stt-local/Dockerfile +12 -0
  67. package/stt-local/server.py +45 -0
  68. package/tts-local/Dockerfile +9 -0
  69. package/tts-local/server.py +83 -0
  70. package/voice-app/API-QUERY-CONTRACT.md +238 -0
  71. package/voice-app/DEPLOYMENT.md +176 -0
  72. package/voice-app/Dockerfile +20 -0
  73. package/voice-app/README-OUTBOUND.md +316 -0
  74. package/voice-app/config/devices.json.example +18 -0
  75. package/voice-app/index.js +273 -0
  76. package/voice-app/lib/audio-fork.js +465 -0
  77. package/voice-app/lib/claude-bridge.js +113 -0
  78. package/voice-app/lib/connection-retry.js +58 -0
  79. package/voice-app/lib/conversation-loop.js +490 -0
  80. package/voice-app/lib/device-registry.js +171 -0
  81. package/voice-app/lib/http-server.js +242 -0
  82. package/voice-app/lib/logger.js +35 -0
  83. package/voice-app/lib/multi-registrar.js +133 -0
  84. package/voice-app/lib/outbound-handler.js +299 -0
  85. package/voice-app/lib/outbound-routes.js +417 -0
  86. package/voice-app/lib/outbound-session.js +324 -0
  87. package/voice-app/lib/query-routes.js +484 -0
  88. package/voice-app/lib/registrar.js +144 -0
  89. package/voice-app/lib/sip-handler.js +490 -0
  90. package/voice-app/lib/tts-service.js +205 -0
  91. package/voice-app/lib/whisper-client.js +140 -0
  92. package/voice-app/package.json +38 -0
  93. package/voice-app/static/gotit-beep.wav +0 -0
  94. package/voice-app/static/hold-music.wav +0 -0
  95. package/voice-app/static/ready-beep.wav +0 -0
  96. 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
+ };