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,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;
|