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,484 @@
1
+ /**
2
+ * Query API Routes
3
+ * Express routes for programmatic Claude queries from n8n or other systems
4
+ * Supports both text and structured JSON responses with device-specific context
5
+ *
6
+ * v2: Uses /ask-structured for JSON format, proper callId for skills access
7
+ */
8
+
9
+ const express = require('express');
10
+ const axios = require('axios');
11
+ const crypto = require('crypto');
12
+ const router = express.Router();
13
+ const logger = require('./logger');
14
+ const deviceRegistry = require('./device-registry');
15
+
16
+ // Dependencies injected via setupRoutes()
17
+ let claudeBridge = null;
18
+
19
+ // Claude API server URL (same as used by claudeBridge)
20
+ const CLAUDE_API_URL = process.env.CLAUDE_API_URL || 'http://localhost:3333';
21
+
22
+ /**
23
+ * Extract voice-friendly line from Claude response
24
+ * Copied from conversation-loop.js for consistency
25
+ */
26
+ function extractVoiceLine(response) {
27
+ /**
28
+ * Clean markdown and formatting from text for speech
29
+ */
30
+ function cleanForSpeech(text) {
31
+ return text
32
+ .replace(/\*+/g, '') // Remove bold/italic markers
33
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert [text](url) to just text
34
+ .replace(/\[([^\]]+)\]/g, '$1') // Remove remaining brackets
35
+ .trim();
36
+ }
37
+
38
+ // Priority 1: Check for new VOICE_RESPONSE line (voice-optimized content)
39
+ const voiceMatch = response.match(/🗣️\s*VOICE_RESPONSE:\s*([^\n]+)/im);
40
+ if (voiceMatch) {
41
+ const text = cleanForSpeech(voiceMatch[1]);
42
+ const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
43
+
44
+ // Accept if under 60 words
45
+ if (text && wordCount <= 60) {
46
+ return text;
47
+ }
48
+
49
+ // If too long, log warning but continue to next fallback
50
+ logger.warn('VOICE_RESPONSE too long, falling back', { wordCount, maxWords: 60 });
51
+ }
52
+
53
+ // Priority 2: Check for legacy CUSTOM COMPLETED line
54
+ const customMatch = response.match(/🗣️\s*CUSTOM\s+COMPLETED:\s*(.+?)(?:\n|$)/im);
55
+ if (customMatch) {
56
+ const text = cleanForSpeech(customMatch[1]);
57
+ if (text && text.split(/\s+/).length <= 50) {
58
+ return text;
59
+ }
60
+ }
61
+
62
+ // Priority 3: Check for standard COMPLETED line
63
+ const completedMatch = response.match(/🎯\s*COMPLETED:\s*(.+?)(?:\n|$)/im);
64
+ if (completedMatch) {
65
+ return cleanForSpeech(completedMatch[1]);
66
+ }
67
+
68
+ // Priority 4: Fallback to first sentence
69
+ const firstSentence = response.split(/[.!?]/)[0];
70
+ if (firstSentence && firstSentence.length < 500) {
71
+ return firstSentence.trim();
72
+ }
73
+
74
+ // Last resort: truncate
75
+ return response.substring(0, 500).trim();
76
+ }
77
+
78
+ /**
79
+ * Extract JSON from Claude response
80
+ * Handles markdown code fences and inline JSON
81
+ */
82
+ function extractJson(text) {
83
+ // Try direct parse
84
+ try {
85
+ return JSON.parse(text.trim());
86
+ } catch {}
87
+
88
+ // Try extracting from markdown fence
89
+ const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
90
+ if (fenceMatch) {
91
+ try {
92
+ return JSON.parse(fenceMatch[1].trim());
93
+ } catch {}
94
+ }
95
+
96
+ // Try finding balanced braces
97
+ const braceMatch = text.match(/\{[\s\S]*\}/);
98
+ if (braceMatch) {
99
+ try {
100
+ return JSON.parse(braceMatch[0]);
101
+ } catch {}
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Build query context for JSON format
109
+ * Forces Claude to return structured JSON with specified schema
110
+ */
111
+ function buildJsonQueryContext(schema) {
112
+ if (!schema || !schema.requiredFields) {
113
+ return '[STRUCTURED QUERY - RESPOND WITH JSON ONLY]\nReturn a valid JSON object. No markdown, no code fences, no explanations.';
114
+ }
115
+
116
+ let context = '[STRUCTURED QUERY - RESPOND WITH JSON ONLY]\n';
117
+ context += 'Return EXACTLY ONE valid JSON object. No markdown, no code fences, no explanations.\n';
118
+ context += `Required fields: ${JSON.stringify(schema.requiredFields)}\n`;
119
+
120
+ if (schema.fieldGuidance) {
121
+ context += `Field guidance: ${JSON.stringify(schema.fieldGuidance)}\n`;
122
+ }
123
+
124
+ return context;
125
+ }
126
+
127
+ /**
128
+ * Validate query request
129
+ */
130
+ function validateQueryRequest(body) {
131
+ if (!body) {
132
+ return { valid: false, error: 'Request body is required' };
133
+ }
134
+
135
+ // Required: 'query' field
136
+ if (!body.query) {
137
+ return { valid: false, error: 'Field "query" is required' };
138
+ }
139
+
140
+ if (typeof body.query !== 'string' || body.query.trim().length === 0) {
141
+ return { valid: false, error: 'Field "query" must be a non-empty string' };
142
+ }
143
+
144
+ if (body.query.length > 2000) {
145
+ return { valid: false, error: 'Field "query" must be 2000 characters or less' };
146
+ }
147
+
148
+ // Optional: 'format' validation
149
+ if (body.format && !['text', 'json'].includes(body.format)) {
150
+ return { valid: false, error: 'Field "format" must be either "text" or "json"' };
151
+ }
152
+
153
+ // If format is json, schema is required
154
+ if (body.format === 'json' && !body.schema) {
155
+ return { valid: false, error: 'Field "schema" is required when format is "json"' };
156
+ }
157
+
158
+ // Validate schema structure if provided
159
+ if (body.schema) {
160
+ if (!body.schema.requiredFields || !Array.isArray(body.schema.requiredFields)) {
161
+ return { valid: false, error: 'schema.requiredFields must be an array' };
162
+ }
163
+
164
+ if (body.schema.requiredFields.length === 0) {
165
+ return { valid: false, error: 'schema.requiredFields must contain at least one field' };
166
+ }
167
+ }
168
+
169
+ // Optional: 'timeout' validation
170
+ if (body.timeout !== undefined) {
171
+ const timeout = Number(body.timeout);
172
+ if (!Number.isInteger(timeout) || timeout < 10 || timeout > 300) {
173
+ return { valid: false, error: 'Field "timeout" must be an integer between 10 and 300 seconds' };
174
+ }
175
+ }
176
+
177
+ return { valid: true };
178
+ }
179
+
180
+ /**
181
+ * POST /query
182
+ * Execute a Claude query with optional device context and structured output
183
+ *
184
+ * v2: Each query gets a unique callId for skills access
185
+ * JSON format uses /ask-structured endpoint for reliable parsing
186
+ */
187
+ router.post('/query', async (req, res) => {
188
+ const startTime = Date.now();
189
+
190
+ try {
191
+ // Validate request
192
+ const validation = validateQueryRequest(req.body);
193
+ if (!validation.valid) {
194
+ logger.warn('Invalid query request', {
195
+ error: validation.error,
196
+ body: req.body
197
+ });
198
+
199
+ return res.status(400).json({
200
+ success: false,
201
+ error: 'validation_failed',
202
+ message: validation.error
203
+ });
204
+ }
205
+
206
+ // Extract parameters
207
+ const {
208
+ query,
209
+ device: deviceIdentifier,
210
+ context: injectedContext,
211
+ format = 'text',
212
+ schema,
213
+ timeout = 120
214
+ } = req.body;
215
+
216
+ // Generate unique callId for this query (enables skills access)
217
+ // Must be a bare UUID - Claude CLI rejects prefixes
218
+ const callId = crypto.randomUUID();
219
+
220
+ // Resolve device if specified
221
+ let device = null;
222
+ let devicePrompt = null;
223
+
224
+ if (deviceIdentifier) {
225
+ device = deviceRegistry.get(deviceIdentifier);
226
+
227
+ if (!device) {
228
+ logger.warn('Device not found, using default', { deviceIdentifier });
229
+ device = deviceRegistry.getDefault();
230
+ }
231
+
232
+ devicePrompt = device.prompt;
233
+ }
234
+
235
+ // Build the full prompt with injected context
236
+ let fullPrompt = query;
237
+
238
+ // Add injected context if provided
239
+ if (injectedContext && typeof injectedContext === 'object') {
240
+ const contextStr = Object.entries(injectedContext)
241
+ .map(([key, value]) => `${key}: ${value}`)
242
+ .join('\n');
243
+
244
+ fullPrompt = `Context:\n${contextStr}\n\n${fullPrompt}`;
245
+ }
246
+
247
+ logger.info('Processing query request', {
248
+ queryLength: query.length,
249
+ device: device ? device.name : 'default',
250
+ format,
251
+ hasSchema: !!schema,
252
+ hasContext: !!injectedContext,
253
+ timeout,
254
+ callId
255
+ });
256
+
257
+ let response;
258
+ let structured = null;
259
+
260
+ if (format === 'json') {
261
+ // Use /ask-structured endpoint for reliable JSON parsing
262
+ logger.info('Using /ask-structured endpoint for JSON format');
263
+
264
+ const structuredResponse = await axios.post(
265
+ `${CLAUDE_API_URL}/ask-structured`,
266
+ {
267
+ prompt: fullPrompt,
268
+ callId,
269
+ devicePrompt,
270
+ schema: {
271
+ queryType: schema?.queryType || 'general',
272
+ requiredFields: schema?.requiredFields || [],
273
+ fieldGuidance: schema?.fieldGuidance || {},
274
+ allowExtraFields: true,
275
+ example: schema?.example
276
+ },
277
+ includeVoiceContext: false,
278
+ maxRetries: 1
279
+ },
280
+ {
281
+ timeout: timeout * 1000,
282
+ headers: { 'Content-Type': 'application/json' }
283
+ }
284
+ );
285
+
286
+ if (structuredResponse.data.success) {
287
+ response = structuredResponse.data.raw_response;
288
+ structured = structuredResponse.data.data;
289
+ } else {
290
+ // Fallback: return raw response even if parsing failed
291
+ response = structuredResponse.data.raw_response || '';
292
+ logger.warn('Structured query returned failure', {
293
+ error: structuredResponse.data.error
294
+ });
295
+ }
296
+ } else {
297
+ // Use /ask endpoint for text format (via claudeBridge)
298
+ // Check if Claude bridge is available
299
+ if (!claudeBridge) {
300
+ logger.error('Claude bridge not available');
301
+
302
+ return res.status(503).json({
303
+ success: false,
304
+ error: 'service_unavailable',
305
+ message: 'Claude API is not ready'
306
+ });
307
+ }
308
+
309
+ response = await claudeBridge.query(fullPrompt, {
310
+ callId,
311
+ devicePrompt,
312
+ timeout
313
+ });
314
+ }
315
+
316
+ const duration = Date.now() - startTime;
317
+
318
+ // Extract voice line
319
+ const voiceLine = extractVoiceLine(response);
320
+
321
+ // Build response object
322
+ const responseObj = {
323
+ success: true,
324
+ response,
325
+ voiceLine,
326
+ duration_ms: duration
327
+ };
328
+
329
+ // Add device info if specified
330
+ if (device) {
331
+ responseObj.device = {
332
+ name: device.name,
333
+ extension: device.extension
334
+ };
335
+ }
336
+
337
+ // Add structured data if JSON format
338
+ if (format === 'json') {
339
+ if (structured) {
340
+ responseObj.structured = structured;
341
+
342
+ logger.info('Query completed with structured output', {
343
+ duration,
344
+ device: device ? device.name : 'default',
345
+ structuredFields: Object.keys(structured)
346
+ });
347
+ } else {
348
+ // Try client-side extraction as fallback
349
+ const fallbackStructured = extractJson(response);
350
+ responseObj.structured = fallbackStructured;
351
+
352
+ if (!fallbackStructured) {
353
+ responseObj.warning = 'Failed to parse JSON from Claude response';
354
+ }
355
+
356
+ logger.warn('Used client-side JSON extraction', {
357
+ duration,
358
+ device: device ? device.name : 'default',
359
+ success: !!fallbackStructured
360
+ });
361
+ }
362
+ } else {
363
+ logger.info('Query completed', {
364
+ duration,
365
+ device: device ? device.name : 'default',
366
+ responseLength: response.length
367
+ });
368
+ }
369
+
370
+ res.json(responseObj);
371
+
372
+ } catch (error) {
373
+ const duration = Date.now() - startTime;
374
+
375
+ logger.error('Query endpoint error', {
376
+ error: error.message,
377
+ stack: error.stack,
378
+ duration
379
+ });
380
+
381
+ res.status(500).json({
382
+ success: false,
383
+ error: 'internal_error',
384
+ message: 'An internal error occurred',
385
+ duration_ms: duration
386
+ });
387
+ }
388
+ });
389
+
390
+ /**
391
+ * GET /devices
392
+ * List all registered devices
393
+ */
394
+ router.get('/devices', (req, res) => {
395
+ try {
396
+ const allDevices = deviceRegistry.getAll();
397
+
398
+ const deviceList = Object.values(allDevices).map(device => ({
399
+ name: device.name,
400
+ extension: device.extension,
401
+ hasVoice: !!device.voiceId,
402
+ hasPrompt: !!device.prompt
403
+ }));
404
+
405
+ res.json({
406
+ success: true,
407
+ count: deviceList.length,
408
+ devices: deviceList
409
+ });
410
+
411
+ } catch (error) {
412
+ logger.error('Get devices error', {
413
+ error: error.message
414
+ });
415
+
416
+ res.status(500).json({
417
+ success: false,
418
+ error: 'internal_error',
419
+ message: 'Failed to retrieve devices'
420
+ });
421
+ }
422
+ });
423
+
424
+ /**
425
+ * GET /device/:identifier
426
+ * Get specific device by name or extension
427
+ */
428
+ router.get('/device/:identifier', (req, res) => {
429
+ try {
430
+ const { identifier } = req.params;
431
+
432
+ const device = deviceRegistry.get(identifier);
433
+
434
+ if (!device) {
435
+ return res.status(404).json({
436
+ success: false,
437
+ error: 'not_found',
438
+ message: 'Device not found'
439
+ });
440
+ }
441
+
442
+ res.json({
443
+ success: true,
444
+ device: {
445
+ name: device.name,
446
+ extension: device.extension,
447
+ hasVoice: !!device.voiceId,
448
+ hasPrompt: !!device.prompt,
449
+ voiceId: device.voiceId
450
+ }
451
+ });
452
+
453
+ } catch (error) {
454
+ logger.error('Get device error', {
455
+ error: error.message,
456
+ identifier: req.params.identifier
457
+ });
458
+
459
+ res.status(500).json({
460
+ success: false,
461
+ error: 'internal_error',
462
+ message: 'Failed to retrieve device'
463
+ });
464
+ }
465
+ });
466
+
467
+ /**
468
+ * Setup routes with dependencies
469
+ *
470
+ * @param {Object} deps - Dependencies
471
+ * @param {Object} deps.claudeBridge - Claude API bridge
472
+ */
473
+ function setupRoutes(deps) {
474
+ claudeBridge = deps.claudeBridge;
475
+
476
+ logger.info('Query routes initialized', {
477
+ claudeBridge: !!claudeBridge
478
+ });
479
+ }
480
+
481
+ module.exports = {
482
+ router,
483
+ setupRoutes
484
+ };
@@ -0,0 +1,144 @@
1
+ const Srf = require('drachtio-srf');
2
+
3
+ /**
4
+ * Handles SIP Registration (UAC) with 3CX
5
+ *
6
+ * 3CX requires:
7
+ * - From/To/Contact: Use EXTENSION NUMBER (e.g., 5756)
8
+ * - Auth credentials: Use AUTH ID + PASSWORD (e.g., pEmNzYscb4)
9
+ */
10
+ class Registrar {
11
+ constructor(srf, config) {
12
+ this.srf = srf;
13
+ this.config = config;
14
+
15
+ // Extension number for From/To/Contact headers
16
+ this.extension = config.extension;
17
+
18
+ // Auth credentials (separate from extension)
19
+ this.authId = config.auth_id;
20
+ this.password = config.password;
21
+
22
+ // Server addresses
23
+ this.domain = config.domain; // 3CX server address
24
+ this.registrar = config.registrar; // Usually same as domain
25
+ this.registrarPort = config.registrar_port || 5060;
26
+
27
+ // Local address (populated from connect event)
28
+ this.localAddress = config.local_address || 'localhost';
29
+
30
+ // Registration state
31
+ this.expiry = config.expiry || 3600;
32
+ this.timer = null;
33
+ this.registered = false;
34
+ }
35
+
36
+ start() {
37
+ console.log('[REGISTRAR] Starting registration:');
38
+ console.log(' Extension: ' + this.extension + '@' + this.domain);
39
+ console.log(' Auth ID: ' + this.authId);
40
+ console.log(' Registrar: ' + this.registrar + ':' + this.registrarPort);
41
+ console.log(' Contact: ' + this.extension + '@' + this.localAddress);
42
+ this._attemptRegistration();
43
+ }
44
+
45
+ stop() {
46
+ if (this.timer) {
47
+ clearTimeout(this.timer);
48
+ this.timer = null;
49
+ }
50
+ this.registered = false;
51
+ console.log('[REGISTRAR] Stopped registration loop');
52
+ }
53
+
54
+ isRegistered() {
55
+ return this.registered;
56
+ }
57
+
58
+ _attemptRegistration() {
59
+ const registrarUri = 'sip:' + this.registrar + ':' + this.registrarPort;
60
+
61
+ // From/To use EXTENSION number, not auth ID
62
+ const fromUri = 'sip:' + this.extension + '@' + this.domain;
63
+
64
+ // Contact uses local address
65
+ const contactUri = 'sip:' + this.extension + '@' + this.localAddress;
66
+
67
+ console.log('[REGISTRAR] Sending REGISTER to ' + registrarUri);
68
+ console.log(' From: ' + fromUri);
69
+ console.log(' Contact: ' + contactUri);
70
+
71
+ try {
72
+ this.srf.request(registrarUri, {
73
+ method: 'REGISTER',
74
+ headers: {
75
+ 'From': '<' + fromUri + '>',
76
+ 'To': '<' + fromUri + '>',
77
+ 'Contact': '<' + contactUri + '>;expires=' + this.expiry,
78
+ 'Expires': this.expiry.toString(),
79
+ 'User-Agent': 'ClaudePhoneLocal-VoiceServer/1.0'
80
+ },
81
+ auth: {
82
+ username: this.authId, // AUTH ID, not extension
83
+ password: this.password
84
+ }
85
+ }, (err, req) => {
86
+ if (err) {
87
+ console.error('[REGISTRAR] Request Error: ' + err.message);
88
+ this._scheduleRetry(60);
89
+ return;
90
+ }
91
+
92
+ req.on('response', (res) => {
93
+ console.log('[REGISTRAR] Response: ' + res.status + ' ' + res.reason);
94
+
95
+ if (res.status === 200) {
96
+ console.log('[REGISTRAR] SUCCESS - Registered as extension ' + this.extension);
97
+ this.registered = true;
98
+
99
+ // Parse granted expiry from Contact or Expires header
100
+ var grantedExpires = this.expiry;
101
+ var contactHeader = res.get('Contact');
102
+ var expiresHeader = res.get('Expires');
103
+
104
+ if (contactHeader) {
105
+ var match = contactHeader.match(/expires=(\d+)/i);
106
+ if (match) grantedExpires = parseInt(match[1]);
107
+ } else if (expiresHeader) {
108
+ grantedExpires = parseInt(expiresHeader);
109
+ }
110
+
111
+ // Refresh at 90% of expiry, minimum 30 seconds
112
+ var refreshTime = Math.max(30, Math.floor(grantedExpires * 0.9));
113
+ console.log('[REGISTRAR] Next refresh in ' + refreshTime + 's (granted ' + grantedExpires + 's)');
114
+ this._scheduleRetry(refreshTime);
115
+
116
+ } else if (res.status === 401 || res.status === 407) {
117
+ // drachtio should handle this automatically with auth object
118
+ // If we're seeing this, auth failed
119
+ console.error('[REGISTRAR] Auth challenge received - credentials may be wrong');
120
+ console.error(' Auth ID used: ' + this.authId);
121
+ this.registered = false;
122
+ this._scheduleRetry(60);
123
+
124
+ } else if (res.status >= 300) {
125
+ console.error('[REGISTRAR] FAILED: ' + res.status + ' ' + res.reason);
126
+ this.registered = false;
127
+ this._scheduleRetry(60);
128
+ }
129
+ });
130
+ });
131
+ } catch (err) {
132
+ console.error('[REGISTRAR] Exception: ' + err.message);
133
+ this._scheduleRetry(60);
134
+ }
135
+ }
136
+
137
+ _scheduleRetry(seconds) {
138
+ if (this.timer) clearTimeout(this.timer);
139
+ console.log('[REGISTRAR] Scheduling retry in ' + seconds + 's');
140
+ this.timer = setTimeout(() => this._attemptRegistration(), seconds * 1000);
141
+ }
142
+ }
143
+
144
+ module.exports = Registrar;