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,417 @@
1
+ /**
2
+ * Outbound Call API Routes
3
+ * Express routes for initiating and managing outbound calls
4
+ * v3: Added context parameter for structured data to Claude
5
+ * Supports both announce (one-way) and conversation (two-way) modes
6
+ */
7
+
8
+ const express = require('express');
9
+ const router = express.Router();
10
+ const logger = require('./logger');
11
+ const { OutboundSession, getSession, getAllSessions } = require('./outbound-session');
12
+ const { initiateOutboundCall, playMessage, hangupCall } = require('./outbound-handler');
13
+ const { runConversationLoop } = require('./conversation-loop');
14
+
15
+ // Dependencies injected via setupRoutes()
16
+ var srf = null;
17
+ var mediaServer = null;
18
+ var deviceRegistry = null;
19
+ var audioForkServer = null;
20
+ var whisperClient = null;
21
+ var claudeBridge = null;
22
+ var ttsService = null;
23
+ var wsPort = 3001;
24
+
25
+ /**
26
+ * Validate phone number format
27
+ */
28
+ function isValidPhoneNumber(phoneNumber) {
29
+ if (typeof phoneNumber !== 'string') return false;
30
+ var e164Regex = /^\+[1-9]\d{1,14}$/;
31
+ var dialStringRegex = /^\d{1,15}$/;
32
+ return e164Regex.test(phoneNumber) || dialStringRegex.test(phoneNumber);
33
+ }
34
+
35
+ /**
36
+ * Validate outbound call request
37
+ */
38
+ function validateRequest(body) {
39
+ if (!body) {
40
+ return { valid: false, error: 'Request body is required' };
41
+ }
42
+
43
+ if (!body.to) {
44
+ return { valid: false, error: 'Field "to" is required' };
45
+ }
46
+
47
+ if (!isValidPhoneNumber(body.to)) {
48
+ return { valid: false, error: 'Field "to" must be a valid phone number (e.g. +15551234567 or extension 5755)' };
49
+ }
50
+
51
+ if (!body.message) {
52
+ return { valid: false, error: 'Field "message" is required' };
53
+ }
54
+
55
+ if (typeof body.message !== 'string' || body.message.trim().length === 0) {
56
+ return { valid: false, error: 'Field "message" must be a non-empty string' };
57
+ }
58
+
59
+ if (body.message.length > 1000) {
60
+ return { valid: false, error: 'Field "message" must be 1000 characters or less' };
61
+ }
62
+
63
+ if (body.callerId && !isValidPhoneNumber(body.callerId)) {
64
+ return { valid: false, error: 'Field "callerId" must be a valid E.164 phone number if provided' };
65
+ }
66
+
67
+ if (body.mode && !['announce', 'conversation'].includes(body.mode)) {
68
+ return { valid: false, error: 'Field "mode" must be either "announce" or "conversation"' };
69
+ }
70
+
71
+ if (body.device !== undefined && typeof body.device !== 'string') {
72
+ return { valid: false, error: 'Field "device" must be a string (extension number or device name)' };
73
+ }
74
+
75
+ // Optional: 'context' validation (string or object)
76
+ if (body.context !== undefined) {
77
+ if (typeof body.context !== 'string' && typeof body.context !== 'object') {
78
+ return { valid: false, error: 'Field "context" must be a string or object' };
79
+ }
80
+ }
81
+
82
+ if (body.timeoutSeconds !== undefined) {
83
+ var timeout = Number(body.timeoutSeconds);
84
+ if (!Number.isInteger(timeout) || timeout < 5 || timeout > 120) {
85
+ return { valid: false, error: 'Field "timeoutSeconds" must be an integer between 5 and 120' };
86
+ }
87
+ }
88
+
89
+ return { valid: true };
90
+ }
91
+
92
+ /**
93
+ * POST /api/outbound-call
94
+ * Initiate an outbound call
95
+ *
96
+ * Body parameters:
97
+ * - to: Phone number (required)
98
+ * - message: Initial message to play (required) - what the device SAYS
99
+ * - context: Background data for Claude (optional) - what the device KNOWS
100
+ * - mode: 'announce' or 'conversation' (default: announce)
101
+ * - device: Device extension or name for voice/personality (optional)
102
+ * - callerId: Caller ID (optional)
103
+ * - timeoutSeconds: Ring timeout (optional, default: 30)
104
+ */
105
+ router.post('/outbound-call', async function(req, res) {
106
+ var startTime = Date.now();
107
+
108
+ try {
109
+ // Validate request
110
+ var validation = validateRequest(req.body);
111
+ if (!validation.valid) {
112
+ logger.warn('Invalid outbound call request', {
113
+ error: validation.error,
114
+ body: req.body
115
+ });
116
+
117
+ return res.status(400).json({
118
+ success: false,
119
+ error: 'validation_failed',
120
+ message: validation.error
121
+ });
122
+ }
123
+
124
+ // Extract parameters
125
+ var to = req.body.to;
126
+ var message = req.body.message;
127
+ var context = req.body.context || null; // NEW: structured context for Claude
128
+ var mode = req.body.mode || 'announce';
129
+ var deviceParam = req.body.device;
130
+ var callerId = req.body.callerId;
131
+ var timeoutSeconds = req.body.timeoutSeconds || 30;
132
+ var webhookUrl = req.body.webhookUrl;
133
+
134
+ // Look up device configuration
135
+ var deviceConfig = null;
136
+ if (deviceParam && deviceRegistry) {
137
+ // Use get() which tries extension first, then name (case-insensitive)
138
+ deviceConfig = deviceRegistry.get(deviceParam);
139
+
140
+ if (deviceConfig) {
141
+ logger.info('Device found for outbound call', {
142
+ device: deviceConfig.name,
143
+ extension: deviceConfig.extension,
144
+ voiceId: deviceConfig.voiceId || 'default'
145
+ });
146
+ } else {
147
+ logger.warn('Device not found, using default', { requested: deviceParam });
148
+ }
149
+ }
150
+
151
+ // Check if infrastructure is available
152
+ if (!srf || !mediaServer) {
153
+ logger.error('Infrastructure not ready', {
154
+ srf: !!srf,
155
+ mediaServer: !!mediaServer
156
+ });
157
+
158
+ return res.status(503).json({
159
+ success: false,
160
+ error: 'service_unavailable',
161
+ message: 'Voice infrastructure is not ready'
162
+ });
163
+ }
164
+
165
+ // For conversation mode, check additional dependencies
166
+ if (mode === 'conversation') {
167
+ if (!audioForkServer || !whisperClient || !claudeBridge || !ttsService) {
168
+ logger.error('Conversation mode dependencies not ready', {
169
+ audioForkServer: !!audioForkServer,
170
+ whisperClient: !!whisperClient,
171
+ claudeBridge: !!claudeBridge,
172
+ ttsService: !!ttsService
173
+ });
174
+
175
+ return res.status(503).json({
176
+ success: false,
177
+ error: 'service_unavailable',
178
+ message: 'Conversation mode dependencies not ready'
179
+ });
180
+ }
181
+ }
182
+
183
+ // Create session
184
+ var session = new OutboundSession(null, {
185
+ to: to,
186
+ message: message,
187
+ mode: mode,
188
+ callerId: callerId,
189
+ webhookUrl: webhookUrl,
190
+ device: deviceConfig ? deviceConfig.name : null
191
+ });
192
+
193
+ var callId = session.callId;
194
+
195
+ logger.info('Processing outbound call request', {
196
+ callId: callId,
197
+ to: to,
198
+ mode: mode,
199
+ device: deviceConfig ? deviceConfig.name : 'default',
200
+ messageLength: message.length,
201
+ hasContext: !!context
202
+ });
203
+
204
+ // Return immediately with callId
205
+ res.json({
206
+ success: true,
207
+ callId: callId,
208
+ status: 'queued',
209
+ message: 'Call initiated',
210
+ device: deviceConfig ? deviceConfig.name : null
211
+ });
212
+
213
+ // Continue asynchronously
214
+ (async function() {
215
+ try {
216
+ session.transition('DIALING');
217
+
218
+ var result = await initiateOutboundCall(srf, mediaServer, {
219
+ to: to,
220
+ message: message,
221
+ callerId: callerId,
222
+ timeoutSeconds: timeoutSeconds,
223
+ deviceConfig: deviceConfig // Pass device for From header display name
224
+ });
225
+
226
+ var dialog = result.dialog;
227
+ var endpoint = result.endpoint;
228
+
229
+ session.setDialog(dialog);
230
+ session.setEndpoint(endpoint);
231
+ session.transition('PLAYING');
232
+
233
+ // Play the initial message with device voice
234
+ var voiceId = (deviceConfig && deviceConfig.voiceId) ? deviceConfig.voiceId : null;
235
+ await playMessage(endpoint, message, { voiceId: voiceId });
236
+
237
+ if (mode === 'announce') {
238
+ await hangupCall(dialog, endpoint, callId);
239
+ session.transition('COMPLETED', 'announce_complete');
240
+
241
+ } else if (mode === 'conversation') {
242
+ logger.info('Entering conversation mode', {
243
+ callId: callId,
244
+ device: deviceConfig ? deviceConfig.name : 'default',
245
+ hasContext: !!context
246
+ });
247
+ session.transition('CONVERSING');
248
+
249
+ try {
250
+ await runConversationLoop(endpoint, dialog, callId, {
251
+ audioForkServer: audioForkServer,
252
+ whisperClient: whisperClient,
253
+ claudeBridge: claudeBridge,
254
+ ttsService: ttsService,
255
+ wsPort: wsPort,
256
+ deviceConfig: deviceConfig,
257
+ initialContext: message,
258
+ context: context, // NEW: pass structured context
259
+ skipGreeting: true,
260
+ maxTurns: 20,
261
+ // Record every exchange on the session so the caller (and the MCP
262
+ // tool) can read the user's answer afterwards via GET /api/call/:id.
263
+ onTurn: function (userText, assistantText) {
264
+ session.recordTurn(userText, assistantText);
265
+ }
266
+ });
267
+
268
+ await hangupCall(dialog, endpoint, callId);
269
+ session.transition('COMPLETED', 'conversation_complete');
270
+
271
+ } catch (convError) {
272
+ logger.error('Conversation loop error', {
273
+ callId: callId,
274
+ error: convError.message
275
+ });
276
+ await hangupCall(dialog, endpoint, callId);
277
+ session.transition('COMPLETED', 'conversation_error');
278
+ }
279
+ }
280
+
281
+ } catch (error) {
282
+ logger.error('Outbound call failed', {
283
+ callId: callId,
284
+ error: error.message,
285
+ elapsed: Date.now() - startTime
286
+ });
287
+
288
+ var reason = 'error';
289
+ if (error.message === 'busy') reason = 'busy';
290
+ else if (error.message === 'no_answer') reason = 'no_answer';
291
+ else if (error.message === 'not_found') reason = 'not_found';
292
+ else if (error.message === 'service_unavailable') reason = 'service_unavailable';
293
+
294
+ session.transition('FAILED', reason);
295
+ }
296
+ })();
297
+
298
+ } catch (error) {
299
+ logger.error('Outbound call endpoint error', {
300
+ error: error.message,
301
+ stack: error.stack
302
+ });
303
+
304
+ res.status(500).json({
305
+ success: false,
306
+ error: 'internal_error',
307
+ message: 'An internal error occurred'
308
+ });
309
+ }
310
+ });
311
+
312
+ /**
313
+ * GET /api/call/:callId
314
+ */
315
+ router.get('/call/:callId', function(req, res) {
316
+ var callId = req.params.callId;
317
+ var session = getSession(callId);
318
+
319
+ if (!session) {
320
+ return res.status(404).json({
321
+ success: false,
322
+ error: 'not_found',
323
+ message: 'Call not found or expired'
324
+ });
325
+ }
326
+
327
+ res.json({
328
+ success: true,
329
+ data: session.getInfo()
330
+ });
331
+ });
332
+
333
+ /**
334
+ * GET /api/calls
335
+ */
336
+ router.get('/calls', function(req, res) {
337
+ var sessions = getAllSessions();
338
+
339
+ res.json({
340
+ success: true,
341
+ count: sessions.length,
342
+ calls: sessions.map(function(s) { return s.getInfo(); })
343
+ });
344
+ });
345
+
346
+ /**
347
+ * POST /api/call/:callId/hangup
348
+ */
349
+ router.post('/call/:callId/hangup', async function(req, res) {
350
+ var callId = req.params.callId;
351
+ var session = getSession(callId);
352
+
353
+ if (!session) {
354
+ return res.status(404).json({
355
+ success: false,
356
+ error: 'not_found',
357
+ message: 'Call not found or expired'
358
+ });
359
+ }
360
+
361
+ if (session.state === 'COMPLETED' || session.state === 'FAILED') {
362
+ return res.status(400).json({
363
+ success: false,
364
+ error: 'already_ended',
365
+ message: 'Call has already ended'
366
+ });
367
+ }
368
+
369
+ try {
370
+ await session.hangup();
371
+
372
+ res.json({
373
+ success: true,
374
+ message: 'Call hangup initiated',
375
+ callId: callId
376
+ });
377
+ } catch (error) {
378
+ logger.error('Failed to hangup call', {
379
+ callId: callId,
380
+ error: error.message
381
+ });
382
+
383
+ res.status(500).json({
384
+ success: false,
385
+ error: 'hangup_failed',
386
+ message: error.message
387
+ });
388
+ }
389
+ });
390
+
391
+ /**
392
+ * Setup routes with dependencies
393
+ */
394
+ function setupRoutes(deps) {
395
+ srf = deps.srf;
396
+ mediaServer = deps.mediaServer;
397
+ deviceRegistry = deps.deviceRegistry || null;
398
+ audioForkServer = deps.audioForkServer || null;
399
+ whisperClient = deps.whisperClient || null;
400
+ claudeBridge = deps.claudeBridge || null;
401
+ ttsService = deps.ttsService || null;
402
+ wsPort = deps.wsPort || 3001;
403
+
404
+ var conversationReady = !!(audioForkServer && whisperClient && claudeBridge && ttsService);
405
+
406
+ logger.info('Outbound routes initialized', {
407
+ srf: !!srf,
408
+ mediaServer: !!mediaServer,
409
+ deviceRegistry: !!deviceRegistry,
410
+ conversationMode: conversationReady ? 'enabled' : 'disabled'
411
+ });
412
+ }
413
+
414
+ module.exports = {
415
+ router: router,
416
+ setupRoutes: setupRoutes
417
+ };