@skillhq/concierge 1.5.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 (138) hide show
  1. package/README.md +91 -0
  2. package/dist/cli/program.d.ts +3 -0
  3. package/dist/cli/program.d.ts.map +1 -0
  4. package/dist/cli/program.js +46 -0
  5. package/dist/cli/program.js.map +1 -0
  6. package/dist/cli/shared.d.ts +18 -0
  7. package/dist/cli/shared.d.ts.map +1 -0
  8. package/dist/cli/shared.js +2 -0
  9. package/dist/cli/shared.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +5 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands/call.d.ts +7 -0
  15. package/dist/commands/call.d.ts.map +1 -0
  16. package/dist/commands/call.js +409 -0
  17. package/dist/commands/call.js.map +1 -0
  18. package/dist/commands/config.d.ts +4 -0
  19. package/dist/commands/config.d.ts.map +1 -0
  20. package/dist/commands/config.js +120 -0
  21. package/dist/commands/config.js.map +1 -0
  22. package/dist/commands/find-contact.d.ts +4 -0
  23. package/dist/commands/find-contact.d.ts.map +1 -0
  24. package/dist/commands/find-contact.js +57 -0
  25. package/dist/commands/find-contact.js.map +1 -0
  26. package/dist/commands/server.d.ts +7 -0
  27. package/dist/commands/server.d.ts.map +1 -0
  28. package/dist/commands/server.js +212 -0
  29. package/dist/commands/server.js.map +1 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +3 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/lib/call/audio/mulaw.d.ts +35 -0
  35. package/dist/lib/call/audio/mulaw.d.ts.map +1 -0
  36. package/dist/lib/call/audio/mulaw.js +109 -0
  37. package/dist/lib/call/audio/mulaw.js.map +1 -0
  38. package/dist/lib/call/audio/pcm-utils.d.ts +62 -0
  39. package/dist/lib/call/audio/pcm-utils.d.ts.map +1 -0
  40. package/dist/lib/call/audio/pcm-utils.js +149 -0
  41. package/dist/lib/call/audio/pcm-utils.js.map +1 -0
  42. package/dist/lib/call/audio/resample.d.ts +34 -0
  43. package/dist/lib/call/audio/resample.d.ts.map +1 -0
  44. package/dist/lib/call/audio/resample.js +97 -0
  45. package/dist/lib/call/audio/resample.js.map +1 -0
  46. package/dist/lib/call/audio/streaming-decoder.d.ts +45 -0
  47. package/dist/lib/call/audio/streaming-decoder.d.ts.map +1 -0
  48. package/dist/lib/call/audio/streaming-decoder.js +110 -0
  49. package/dist/lib/call/audio/streaming-decoder.js.map +1 -0
  50. package/dist/lib/call/call-server.d.ts +110 -0
  51. package/dist/lib/call/call-server.d.ts.map +1 -0
  52. package/dist/lib/call/call-server.js +681 -0
  53. package/dist/lib/call/call-server.js.map +1 -0
  54. package/dist/lib/call/call-session.d.ts +133 -0
  55. package/dist/lib/call/call-session.d.ts.map +1 -0
  56. package/dist/lib/call/call-session.js +890 -0
  57. package/dist/lib/call/call-session.js.map +1 -0
  58. package/dist/lib/call/call-types.d.ts +133 -0
  59. package/dist/lib/call/call-types.d.ts.map +1 -0
  60. package/dist/lib/call/call-types.js +16 -0
  61. package/dist/lib/call/call-types.js.map +1 -0
  62. package/dist/lib/call/conversation-ai.d.ts +56 -0
  63. package/dist/lib/call/conversation-ai.d.ts.map +1 -0
  64. package/dist/lib/call/conversation-ai.js +276 -0
  65. package/dist/lib/call/conversation-ai.js.map +1 -0
  66. package/dist/lib/call/eval/codec-test.d.ts +45 -0
  67. package/dist/lib/call/eval/codec-test.d.ts.map +1 -0
  68. package/dist/lib/call/eval/codec-test.js +169 -0
  69. package/dist/lib/call/eval/codec-test.js.map +1 -0
  70. package/dist/lib/call/eval/conversation-scripts.d.ts +55 -0
  71. package/dist/lib/call/eval/conversation-scripts.d.ts.map +1 -0
  72. package/dist/lib/call/eval/conversation-scripts.js +359 -0
  73. package/dist/lib/call/eval/conversation-scripts.js.map +1 -0
  74. package/dist/lib/call/eval/eval-runner.d.ts +64 -0
  75. package/dist/lib/call/eval/eval-runner.d.ts.map +1 -0
  76. package/dist/lib/call/eval/eval-runner.js +369 -0
  77. package/dist/lib/call/eval/eval-runner.js.map +1 -0
  78. package/dist/lib/call/eval/index.d.ts +9 -0
  79. package/dist/lib/call/eval/index.d.ts.map +1 -0
  80. package/dist/lib/call/eval/index.js +9 -0
  81. package/dist/lib/call/eval/index.js.map +1 -0
  82. package/dist/lib/call/eval/integration-test-suite.d.ts +71 -0
  83. package/dist/lib/call/eval/integration-test-suite.d.ts.map +1 -0
  84. package/dist/lib/call/eval/integration-test-suite.js +519 -0
  85. package/dist/lib/call/eval/integration-test-suite.js.map +1 -0
  86. package/dist/lib/call/eval/turn-taking-test.d.ts +84 -0
  87. package/dist/lib/call/eval/turn-taking-test.d.ts.map +1 -0
  88. package/dist/lib/call/eval/turn-taking-test.js +260 -0
  89. package/dist/lib/call/eval/turn-taking-test.js.map +1 -0
  90. package/dist/lib/call/index.d.ts +12 -0
  91. package/dist/lib/call/index.d.ts.map +1 -0
  92. package/dist/lib/call/index.js +17 -0
  93. package/dist/lib/call/index.js.map +1 -0
  94. package/dist/lib/call/providers/deepgram.d.ts +81 -0
  95. package/dist/lib/call/providers/deepgram.d.ts.map +1 -0
  96. package/dist/lib/call/providers/deepgram.js +279 -0
  97. package/dist/lib/call/providers/deepgram.js.map +1 -0
  98. package/dist/lib/call/providers/elevenlabs.d.ts +78 -0
  99. package/dist/lib/call/providers/elevenlabs.d.ts.map +1 -0
  100. package/dist/lib/call/providers/elevenlabs.js +272 -0
  101. package/dist/lib/call/providers/elevenlabs.js.map +1 -0
  102. package/dist/lib/call/providers/local-deps.d.ts +18 -0
  103. package/dist/lib/call/providers/local-deps.d.ts.map +1 -0
  104. package/dist/lib/call/providers/local-deps.js +114 -0
  105. package/dist/lib/call/providers/local-deps.js.map +1 -0
  106. package/dist/lib/call/providers/twilio.d.ts +53 -0
  107. package/dist/lib/call/providers/twilio.d.ts.map +1 -0
  108. package/dist/lib/call/providers/twilio.js +173 -0
  109. package/dist/lib/call/providers/twilio.js.map +1 -0
  110. package/dist/lib/concierge-client-types.d.ts +68 -0
  111. package/dist/lib/concierge-client-types.d.ts.map +1 -0
  112. package/dist/lib/concierge-client-types.js +2 -0
  113. package/dist/lib/concierge-client-types.js.map +1 -0
  114. package/dist/lib/concierge-client.d.ts +29 -0
  115. package/dist/lib/concierge-client.d.ts.map +1 -0
  116. package/dist/lib/concierge-client.js +534 -0
  117. package/dist/lib/concierge-client.js.map +1 -0
  118. package/dist/lib/config.d.ts +9 -0
  119. package/dist/lib/config.d.ts.map +1 -0
  120. package/dist/lib/config.js +66 -0
  121. package/dist/lib/config.js.map +1 -0
  122. package/dist/lib/output.d.ts +7 -0
  123. package/dist/lib/output.d.ts.map +1 -0
  124. package/dist/lib/output.js +114 -0
  125. package/dist/lib/output.js.map +1 -0
  126. package/dist/lib/utils/contact-extractor.d.ts +12 -0
  127. package/dist/lib/utils/contact-extractor.d.ts.map +1 -0
  128. package/dist/lib/utils/contact-extractor.js +159 -0
  129. package/dist/lib/utils/contact-extractor.js.map +1 -0
  130. package/dist/lib/utils/formatters.d.ts +15 -0
  131. package/dist/lib/utils/formatters.d.ts.map +1 -0
  132. package/dist/lib/utils/formatters.js +107 -0
  133. package/dist/lib/utils/formatters.js.map +1 -0
  134. package/dist/lib/utils/url-parser.d.ts +11 -0
  135. package/dist/lib/utils/url-parser.d.ts.map +1 -0
  136. package/dist/lib/utils/url-parser.js +103 -0
  137. package/dist/lib/utils/url-parser.js.map +1 -0
  138. package/package.json +67 -0
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Call server - HTTP + WebSocket server for voice calls
3
+ */
4
+ import { randomUUID } from 'node:crypto';
5
+ import { EventEmitter } from 'node:events';
6
+ import { createServer } from 'node:http';
7
+ import { WebSocket, WebSocketServer } from 'ws';
8
+ import { CallSession } from './call-session.js';
9
+ import { preflightDeepgramSTT } from './providers/deepgram.js';
10
+ import { preflightElevenLabsTTSBudget } from './providers/elevenlabs.js';
11
+ import { preflightFfmpeg } from './providers/local-deps.js';
12
+ import { formatPhoneNumber, generateErrorTwiml, generateMediaStreamsTwiml, getCallStatus, initiateCall, parseWebhookBody, preflightTwilioCallSetup, validateWebhookSignature, } from './providers/twilio.js';
13
+ // Maximum request body size (1MB)
14
+ const MAX_BODY_SIZE = 1024 * 1024;
15
+ // Maximum lengths for call request fields
16
+ const MAX_PHONE_LENGTH = 20;
17
+ const MAX_GOAL_LENGTH = 1000;
18
+ const MAX_CONTEXT_LENGTH = 5000;
19
+ const TERMINAL_CALL_STATUSES = new Set(['completed', 'busy', 'failed', 'no-answer', 'canceled']);
20
+ const STATUS_RECONCILE_INTERVAL_MS = 10000;
21
+ const PUBLIC_WEBHOOK_PREFLIGHT_TIMEOUT_MS = 6000;
22
+ export class CallServer extends EventEmitter {
23
+ server = null;
24
+ controlWss = null;
25
+ mediaWss = null;
26
+ options;
27
+ sessions = new Map();
28
+ controlClients = new Set();
29
+ statusReconcileTimer = null;
30
+ isPreflightCallId(callId) {
31
+ return !!callId && callId.startsWith('preflight-');
32
+ }
33
+ constructor(options) {
34
+ super();
35
+ this.options = options;
36
+ }
37
+ timestamp() {
38
+ return new Date().toISOString();
39
+ }
40
+ log(message) {
41
+ console.log(`[${this.timestamp()}] ${message}`);
42
+ }
43
+ warn(message) {
44
+ console.warn(`[${this.timestamp()}] ${message}`);
45
+ }
46
+ error(message, error) {
47
+ if (error !== undefined) {
48
+ console.error(`[${this.timestamp()}] ${message}`, error);
49
+ }
50
+ else {
51
+ console.error(`[${this.timestamp()}] ${message}`);
52
+ }
53
+ }
54
+ /**
55
+ * Start the server
56
+ */
57
+ async start() {
58
+ return new Promise((resolve, reject) => {
59
+ try {
60
+ // Create HTTP server
61
+ this.server = createServer((req, res) => this.handleHttpRequest(req, res));
62
+ // Create WebSocket servers
63
+ this.controlWss = new WebSocketServer({ noServer: true });
64
+ this.mediaWss = new WebSocketServer({ noServer: true });
65
+ // Handle WebSocket upgrades
66
+ this.server.on('upgrade', (request, socket, head) => {
67
+ const url = new URL(request.url ?? '/', `http://${request.headers.host}`);
68
+ const pathname = url.pathname;
69
+ this.log(`[Server] WebSocket upgrade request: ${pathname}`);
70
+ if (pathname === '/control') {
71
+ this.log('[Server] Handling /control WebSocket upgrade');
72
+ this.controlWss?.handleUpgrade(request, socket, head, (ws) => {
73
+ this.handleControlConnection(ws);
74
+ });
75
+ }
76
+ else if (pathname.startsWith('/media-stream')) {
77
+ // Twilio doesn't pass query params in WebSocket URL - callId comes in 'start' event
78
+ this.log('[Server] Handling /media-stream WebSocket upgrade');
79
+ this.mediaWss?.handleUpgrade(request, socket, head, (ws) => {
80
+ this.handleMediaStreamConnection(ws);
81
+ });
82
+ }
83
+ else {
84
+ this.log(`[Server] Unknown WebSocket path: ${pathname}, destroying socket`);
85
+ socket.destroy();
86
+ }
87
+ });
88
+ this.server.listen(this.options.port, () => {
89
+ this.log(`Call server listening on port ${this.options.port}`);
90
+ this.log(`Public URL: ${this.options.publicUrl}`);
91
+ this.startStatusReconcileLoop();
92
+ this.emit('started');
93
+ resolve();
94
+ });
95
+ this.server.on('error', (err) => {
96
+ this.emit('error', err);
97
+ reject(err);
98
+ });
99
+ }
100
+ catch (err) {
101
+ reject(err);
102
+ }
103
+ });
104
+ }
105
+ /**
106
+ * Stop the server
107
+ */
108
+ async stop() {
109
+ if (this.statusReconcileTimer) {
110
+ clearInterval(this.statusReconcileTimer);
111
+ this.statusReconcileTimer = null;
112
+ }
113
+ // End all active calls
114
+ for (const session of this.sessions.values()) {
115
+ await session.hangup();
116
+ }
117
+ this.sessions.clear();
118
+ // Close control clients
119
+ for (const client of this.controlClients) {
120
+ client.close();
121
+ }
122
+ this.controlClients.clear();
123
+ // Close WebSocket servers
124
+ this.controlWss?.close();
125
+ this.mediaWss?.close();
126
+ // Close HTTP server
127
+ return new Promise((resolve) => {
128
+ if (this.server) {
129
+ this.server.close(() => {
130
+ this.server = null;
131
+ this.emit('stopped');
132
+ resolve();
133
+ });
134
+ }
135
+ else {
136
+ resolve();
137
+ }
138
+ });
139
+ }
140
+ /**
141
+ * Handle HTTP requests
142
+ */
143
+ handleHttpRequest(req, res) {
144
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
145
+ const method = req.method ?? 'GET';
146
+ // CORS headers for local development
147
+ res.setHeader('Access-Control-Allow-Origin', '*');
148
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
149
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
150
+ if (method === 'OPTIONS') {
151
+ res.writeHead(204);
152
+ res.end();
153
+ return;
154
+ }
155
+ // Route requests
156
+ if (method === 'GET' && url.pathname === '/health') {
157
+ this.handleHealthCheck(res);
158
+ }
159
+ else if (method === 'GET' && url.pathname === '/status') {
160
+ this.handleStatusCheck(res);
161
+ }
162
+ else if (method === 'POST' && url.pathname === '/call') {
163
+ this.handleCallRequest(req, res);
164
+ }
165
+ else if ((method === 'POST' || method === 'GET') && url.pathname === '/twilio/voice') {
166
+ this.handleTwilioVoice(req, res, url);
167
+ }
168
+ else if ((method === 'POST' || method === 'GET') && url.pathname === '/twilio/status') {
169
+ this.handleTwilioStatus(req, res, url);
170
+ }
171
+ else if (method === 'GET' && url.pathname.startsWith('/status/')) {
172
+ this.handleCallStatusCheck(res, url.pathname.split('/').pop() ?? '');
173
+ }
174
+ else {
175
+ this.warn(`[HTTP] Unhandled request ${method} ${url.pathname}`);
176
+ res.writeHead(404, { 'Content-Type': 'application/json' });
177
+ res.end(JSON.stringify({ error: 'Not found' }));
178
+ }
179
+ }
180
+ /**
181
+ * Health check endpoint
182
+ */
183
+ handleHealthCheck(res) {
184
+ res.writeHead(200, { 'Content-Type': 'application/json' });
185
+ res.end(JSON.stringify({ status: 'ok' }));
186
+ }
187
+ /**
188
+ * Server status endpoint
189
+ */
190
+ handleStatusCheck(res) {
191
+ res.writeHead(200, { 'Content-Type': 'application/json' });
192
+ res.end(JSON.stringify({
193
+ status: 'running',
194
+ activeCalls: this.sessions.size,
195
+ controlClients: this.controlClients.size,
196
+ publicUrl: this.options.publicUrl,
197
+ }));
198
+ }
199
+ /**
200
+ * Call status endpoint
201
+ */
202
+ handleCallStatusCheck(res, callId) {
203
+ const session = this.sessions.get(callId);
204
+ if (session) {
205
+ res.writeHead(200, { 'Content-Type': 'application/json' });
206
+ res.end(JSON.stringify(session.getState()));
207
+ }
208
+ else {
209
+ res.writeHead(404, { 'Content-Type': 'application/json' });
210
+ res.end(JSON.stringify({ error: 'Call not found' }));
211
+ }
212
+ }
213
+ /**
214
+ * Initiate a new call via HTTP
215
+ */
216
+ async handleCallRequest(req, res) {
217
+ let body = '';
218
+ let bodySize = 0;
219
+ req.on('data', (chunk) => {
220
+ bodySize += chunk.length;
221
+ if (bodySize > MAX_BODY_SIZE) {
222
+ res.writeHead(413, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ error: 'Request body too large' }));
224
+ req.destroy();
225
+ return;
226
+ }
227
+ body += chunk.toString();
228
+ });
229
+ req.on('end', async () => {
230
+ try {
231
+ const data = JSON.parse(body);
232
+ // Input validation
233
+ if (!data.phoneNumber || typeof data.phoneNumber !== 'string') {
234
+ res.writeHead(400, { 'Content-Type': 'application/json' });
235
+ res.end(JSON.stringify({ error: 'phoneNumber is required' }));
236
+ return;
237
+ }
238
+ if (!data.goal || typeof data.goal !== 'string') {
239
+ res.writeHead(400, { 'Content-Type': 'application/json' });
240
+ res.end(JSON.stringify({ error: 'goal is required' }));
241
+ return;
242
+ }
243
+ if (data.phoneNumber.length > MAX_PHONE_LENGTH) {
244
+ res.writeHead(400, { 'Content-Type': 'application/json' });
245
+ res.end(JSON.stringify({ error: 'phoneNumber too long' }));
246
+ return;
247
+ }
248
+ if (data.goal.length > MAX_GOAL_LENGTH) {
249
+ res.writeHead(400, { 'Content-Type': 'application/json' });
250
+ res.end(JSON.stringify({ error: 'goal too long' }));
251
+ return;
252
+ }
253
+ if (data.context && data.context.length > MAX_CONTEXT_LENGTH) {
254
+ res.writeHead(400, { 'Content-Type': 'application/json' });
255
+ res.end(JSON.stringify({ error: 'context too long' }));
256
+ return;
257
+ }
258
+ const callId = await this.initiateCallInternal(data.phoneNumber, data.goal, data.context);
259
+ res.writeHead(200, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ callId, status: 'initiating' }));
261
+ }
262
+ catch (err) {
263
+ const message = err instanceof Error ? err.message : 'Internal server error';
264
+ const statusCode = message.toLowerCase().includes('preflight') ? 400 : 500;
265
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
266
+ res.end(JSON.stringify({ error: message }));
267
+ }
268
+ });
269
+ }
270
+ /**
271
+ * Twilio voice webhook - returns TwiML for Media Streams
272
+ */
273
+ handleTwilioVoice(req, res, url) {
274
+ let body = '';
275
+ let bodySize = 0;
276
+ req.on('data', (chunk) => {
277
+ bodySize += chunk.length;
278
+ if (bodySize > MAX_BODY_SIZE) {
279
+ res.writeHead(413, { 'Content-Type': 'application/json' });
280
+ res.end(JSON.stringify({ error: 'Request body too large' }));
281
+ req.destroy();
282
+ return;
283
+ }
284
+ body += chunk.toString();
285
+ });
286
+ req.on('end', () => {
287
+ // Validate Twilio webhook signature
288
+ const signature = req.headers['x-twilio-signature'];
289
+ const webhookUrl = `${this.options.publicUrl}${req.url}`;
290
+ const params = parseWebhookBody(body);
291
+ const callId = url.searchParams.get('callId');
292
+ this.log(`[Twilio] /voice webhook received callId=${callId ?? 'missing'} signature=${signature ? 'present' : 'missing'}`);
293
+ if (signature &&
294
+ !validateWebhookSignature(this.options.config, signature, webhookUrl, params)) {
295
+ this.warn('[Twilio] Invalid webhook signature');
296
+ res.writeHead(403, { 'Content-Type': 'application/json' });
297
+ res.end(JSON.stringify({ error: 'Invalid signature' }));
298
+ return;
299
+ }
300
+ if (!callId || !this.sessions.has(callId)) {
301
+ if (!this.isPreflightCallId(callId)) {
302
+ this.warn(`[Twilio] /voice webhook has unknown callId=${callId ?? 'missing'}`);
303
+ }
304
+ res.writeHead(200, { 'Content-Type': 'application/xml' });
305
+ res.end(generateErrorTwiml('Sorry, this call cannot be connected. Please try again later.'));
306
+ return;
307
+ }
308
+ res.writeHead(200, { 'Content-Type': 'application/xml' });
309
+ res.end(generateMediaStreamsTwiml(this.options.config, callId));
310
+ });
311
+ }
312
+ /**
313
+ * Twilio status callback
314
+ */
315
+ handleTwilioStatus(req, res, url) {
316
+ let body = '';
317
+ let bodySize = 0;
318
+ req.on('data', (chunk) => {
319
+ bodySize += chunk.length;
320
+ if (bodySize > MAX_BODY_SIZE) {
321
+ res.writeHead(413, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({ error: 'Request body too large' }));
323
+ req.destroy();
324
+ return;
325
+ }
326
+ body += chunk.toString();
327
+ });
328
+ req.on('end', () => {
329
+ // Validate Twilio webhook signature
330
+ const signature = req.headers['x-twilio-signature'];
331
+ const webhookUrl = `${this.options.publicUrl}${req.url}`;
332
+ const params = parseWebhookBody(body);
333
+ if (signature &&
334
+ !validateWebhookSignature(this.options.config, signature, webhookUrl, params)) {
335
+ this.warn('[Twilio] Invalid webhook signature');
336
+ res.writeHead(403, { 'Content-Type': 'application/json' });
337
+ res.end(JSON.stringify({ error: 'Invalid signature' }));
338
+ return;
339
+ }
340
+ const callId = url.searchParams.get('callId');
341
+ const webhook = params;
342
+ const session = callId ? this.sessions.get(callId) : null;
343
+ const status = webhook.CallStatus;
344
+ this.log(`[Twilio] /status callback callId=${callId ?? 'missing'} status=${status ?? 'unknown'} callSid=${webhook.CallSid ?? 'unknown'}`);
345
+ if (session) {
346
+ switch (status) {
347
+ case 'ringing':
348
+ session.updateStatus('ringing');
349
+ this.broadcastToControl({ type: 'call_ringing', callId: session.callId });
350
+ break;
351
+ case 'in-progress':
352
+ // Keep status in sync for cases where media stream never starts.
353
+ session.updateStatus('in-progress');
354
+ break;
355
+ case 'completed':
356
+ case 'busy':
357
+ case 'failed':
358
+ case 'no-answer':
359
+ case 'canceled':
360
+ session.endFromProviderStatus(status);
361
+ break;
362
+ }
363
+ }
364
+ else if (callId) {
365
+ if (!this.isPreflightCallId(callId)) {
366
+ this.warn(`[Twilio] /status callback for unknown callId=${callId}`);
367
+ }
368
+ }
369
+ if (status && TERMINAL_CALL_STATUSES.has(status) && !session && !this.isPreflightCallId(callId)) {
370
+ this.warn(`[Twilio] Terminal status received without active session: ${status}`);
371
+ }
372
+ res.writeHead(200, { 'Content-Type': 'application/json' });
373
+ res.end(JSON.stringify({ received: true }));
374
+ });
375
+ }
376
+ /**
377
+ * Handle control WebSocket connection
378
+ */
379
+ handleControlConnection(ws) {
380
+ this.log('[Control] Client connected');
381
+ this.controlClients.add(ws);
382
+ ws.on('message', async (data) => {
383
+ try {
384
+ const msg = JSON.parse(data.toString());
385
+ await this.handleControlMessage(ws, msg);
386
+ }
387
+ catch (err) {
388
+ ws.send(JSON.stringify({
389
+ type: 'error',
390
+ message: err instanceof Error ? err.message : 'Invalid message',
391
+ }));
392
+ }
393
+ });
394
+ ws.on('close', () => {
395
+ this.log('[Control] Client disconnected');
396
+ this.controlClients.delete(ws);
397
+ });
398
+ ws.on('error', (err) => {
399
+ this.error('[Control] Error:', err);
400
+ this.controlClients.delete(ws);
401
+ });
402
+ }
403
+ /**
404
+ * Handle control messages from clients
405
+ */
406
+ async handleControlMessage(ws, msg) {
407
+ switch (msg.type) {
408
+ case 'initiate_call': {
409
+ // initiateCallInternal broadcasts call_started to all control clients,
410
+ // so we don't need to send it directly to avoid duplicate events
411
+ await this.initiateCallInternal(msg.phoneNumber, msg.goal, msg.context);
412
+ break;
413
+ }
414
+ case 'speak': {
415
+ const session = this.sessions.get(msg.callId);
416
+ if (session) {
417
+ await session.speak(msg.text);
418
+ }
419
+ else {
420
+ ws.send(JSON.stringify({
421
+ type: 'error',
422
+ callId: msg.callId,
423
+ message: 'Call not found',
424
+ }));
425
+ }
426
+ break;
427
+ }
428
+ case 'hangup': {
429
+ const session = this.sessions.get(msg.callId);
430
+ if (session) {
431
+ await session.hangup();
432
+ }
433
+ break;
434
+ }
435
+ }
436
+ }
437
+ /**
438
+ * Handle media stream WebSocket connection
439
+ * Twilio sends callId in the 'start' event's customParameters, not in the URL
440
+ */
441
+ handleMediaStreamConnection(ws) {
442
+ this.log('[Media] Stream WebSocket connected, waiting for start event...');
443
+ this.log(`[Media] Active sessions: ${[...this.sessions.keys()].join(', ')}`);
444
+ let sessionInitialized = false;
445
+ ws.on('message', (data) => {
446
+ try {
447
+ const msg = JSON.parse(data.toString());
448
+ // Handle the 'start' event to get callId from customParameters
449
+ if (msg.event === 'start' && !sessionInitialized) {
450
+ const callId = msg.start?.customParameters?.callId;
451
+ this.log(`[Media] Received start event, callId: ${callId}`);
452
+ if (!callId) {
453
+ this.error('[Media] No callId in start event customParameters');
454
+ ws.close(1008, 'Missing callId');
455
+ return;
456
+ }
457
+ const session = this.sessions.get(callId);
458
+ if (!session) {
459
+ this.error(`[Media] No session found for call ${callId}`);
460
+ ws.close(1008, 'Call not found');
461
+ return;
462
+ }
463
+ sessionInitialized = true;
464
+ this.log('[Media] Found session, initializing media stream...');
465
+ session
466
+ .initializeMediaStream(ws, msg)
467
+ .then(() => {
468
+ this.log('[Media] Media stream initialized successfully');
469
+ })
470
+ .catch((err) => {
471
+ this.error('[Media] Failed to initialize:', err);
472
+ // Clean up session on initialization failure
473
+ this.sessions.delete(callId);
474
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
475
+ ws.close(1011, 'Failed to initialize');
476
+ });
477
+ }
478
+ }
479
+ catch (err) {
480
+ this.error('[Media] Error parsing message:', err);
481
+ }
482
+ });
483
+ ws.on('close', () => {
484
+ this.log('[Media] WebSocket closed');
485
+ });
486
+ ws.on('error', (err) => {
487
+ this.error('[Media] WebSocket error:', err);
488
+ });
489
+ }
490
+ /**
491
+ * Internal method to initiate a call
492
+ */
493
+ async initiateCallInternal(phoneNumber, goal, context) {
494
+ const [ffmpegPreflight, twilioPreflight, deepgramPreflight, elevenLabsPreflight] = await Promise.all([
495
+ preflightFfmpeg(),
496
+ preflightTwilioCallSetup(this.options.config),
497
+ preflightDeepgramSTT(this.options.config.deepgramApiKey),
498
+ preflightElevenLabsTTSBudget(this.options.config.elevenLabsApiKey, goal, context),
499
+ ]);
500
+ const failedPreflight = [ffmpegPreflight, twilioPreflight, deepgramPreflight, elevenLabsPreflight].find((result) => !result.ok);
501
+ if (failedPreflight) {
502
+ throw new Error(failedPreflight.message);
503
+ }
504
+ this.log(`[Preflight] ${ffmpegPreflight.message}`);
505
+ this.log(`[Preflight] ${twilioPreflight.message}`);
506
+ this.log(`[Preflight] ${deepgramPreflight.message}`);
507
+ this.log(`[Preflight] ${elevenLabsPreflight.message}`);
508
+ const publicWebhookPreflight = await this.preflightPublicWebhook();
509
+ if (!publicWebhookPreflight.ok) {
510
+ throw new Error(publicWebhookPreflight.message);
511
+ }
512
+ this.log(`[Preflight] ${publicWebhookPreflight.message}`);
513
+ const callId = randomUUID();
514
+ const formattedNumber = formatPhoneNumber(phoneNumber);
515
+ // Create session
516
+ const session = new CallSession(callId, this.options.config, formattedNumber, goal, context);
517
+ // Forward session events to control clients
518
+ session.on('message', (msg) => {
519
+ this.broadcastToControl(msg);
520
+ });
521
+ session.on('ended', (state) => {
522
+ this.sessions.delete(callId);
523
+ this.emit('call_ended', callId, state);
524
+ });
525
+ this.sessions.set(callId, session);
526
+ // Initiate call via Twilio
527
+ try {
528
+ const result = await initiateCall(this.options.config, formattedNumber, callId);
529
+ session.setCallSid(result.callSid);
530
+ this.emit('call_started', callId);
531
+ this.broadcastToControl({
532
+ type: 'call_started',
533
+ callId,
534
+ callSid: result.callSid,
535
+ });
536
+ return callId;
537
+ }
538
+ catch (err) {
539
+ this.sessions.delete(callId);
540
+ throw err;
541
+ }
542
+ }
543
+ /**
544
+ * Broadcast message to all control clients
545
+ */
546
+ broadcastToControl(msg) {
547
+ const data = JSON.stringify(msg);
548
+ for (const client of this.controlClients) {
549
+ if (client.readyState === WebSocket.OPEN) {
550
+ client.send(data);
551
+ }
552
+ }
553
+ }
554
+ /**
555
+ * Get a session by call ID
556
+ */
557
+ getSession(callId) {
558
+ return this.sessions.get(callId);
559
+ }
560
+ /**
561
+ * Get all active sessions
562
+ */
563
+ getActiveSessions() {
564
+ return new Map(this.sessions);
565
+ }
566
+ /**
567
+ * Check if server is running
568
+ */
569
+ get isRunning() {
570
+ return this.server !== null;
571
+ }
572
+ startStatusReconcileLoop() {
573
+ if (this.statusReconcileTimer) {
574
+ clearInterval(this.statusReconcileTimer);
575
+ }
576
+ this.statusReconcileTimer = setInterval(() => {
577
+ this.reconcileStatusesWithProvider().catch((err) => {
578
+ this.warn(`[Twilio] Status reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
579
+ });
580
+ }, STATUS_RECONCILE_INTERVAL_MS);
581
+ }
582
+ async reconcileStatusesWithProvider() {
583
+ if (this.sessions.size === 0)
584
+ return;
585
+ for (const session of this.sessions.values()) {
586
+ const state = session.getState();
587
+ const callSid = state.callSid;
588
+ if (!callSid)
589
+ continue;
590
+ if (TERMINAL_CALL_STATUSES.has(state.status))
591
+ continue;
592
+ const providerStatus = await getCallStatus(this.options.config, callSid);
593
+ const normalized = providerStatus;
594
+ if (normalized === 'ringing' && state.status !== 'ringing') {
595
+ session.updateStatus('ringing');
596
+ this.broadcastToControl({ type: 'call_ringing', callId: state.callId });
597
+ }
598
+ else if (normalized === 'in-progress' && state.status !== 'in-progress') {
599
+ session.updateStatus('in-progress');
600
+ }
601
+ else if (normalized === 'completed' || normalized === 'busy' || normalized === 'failed' || normalized === 'no-answer' || normalized === 'canceled') {
602
+ const terminalStatus = normalized;
603
+ this.log(`[Twilio] Reconciled terminal status callId=${state.callId} status=${terminalStatus}`);
604
+ session.endFromProviderStatus(terminalStatus);
605
+ }
606
+ }
607
+ }
608
+ async preflightPublicWebhook() {
609
+ const publicUrl = this.options.publicUrl.replace(/\/+$/, '');
610
+ if (!publicUrl.startsWith('https://') && !publicUrl.startsWith('http://')) {
611
+ return {
612
+ ok: false,
613
+ message: `Public webhook preflight failed: invalid publicUrl "${this.options.publicUrl}".`,
614
+ };
615
+ }
616
+ const controller = new AbortController();
617
+ const timeout = setTimeout(() => controller.abort(), PUBLIC_WEBHOOK_PREFLIGHT_TIMEOUT_MS);
618
+ const preflightCallId = `preflight-${randomUUID().slice(0, 8)}`;
619
+ try {
620
+ const healthResponse = await fetch(`${publicUrl}/health`, {
621
+ method: 'GET',
622
+ signal: controller.signal,
623
+ });
624
+ if (!healthResponse.ok) {
625
+ return {
626
+ ok: false,
627
+ message: `Public webhook preflight failed: ${publicUrl}/health returned HTTP ${healthResponse.status}.`,
628
+ };
629
+ }
630
+ const voiceResponse = await fetch(`${publicUrl}/twilio/voice?callId=${encodeURIComponent(preflightCallId)}`, {
631
+ method: 'POST',
632
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
633
+ body: 'CallSid=CApreflight&CallStatus=ringing',
634
+ signal: controller.signal,
635
+ });
636
+ if (!voiceResponse.ok) {
637
+ return {
638
+ ok: false,
639
+ message: `Public webhook preflight failed: ${publicUrl}/twilio/voice returned HTTP ${voiceResponse.status}.`,
640
+ };
641
+ }
642
+ const statusResponse = await fetch(`${publicUrl}/twilio/status?callId=${encodeURIComponent(preflightCallId)}`, {
643
+ method: 'POST',
644
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
645
+ body: 'CallSid=CApreflight&CallStatus=ringing',
646
+ signal: controller.signal,
647
+ });
648
+ if (!statusResponse.ok) {
649
+ return {
650
+ ok: false,
651
+ message: `Public webhook preflight failed: ${publicUrl}/twilio/status returned HTTP ${statusResponse.status}.`,
652
+ };
653
+ }
654
+ return {
655
+ ok: true,
656
+ message: `Public webhook preflight passed: ${publicUrl} is reachable for Twilio voice and status callbacks.`,
657
+ };
658
+ }
659
+ catch (err) {
660
+ const detail = err instanceof Error ? err.message : String(err);
661
+ return {
662
+ ok: false,
663
+ message: `Public webhook preflight failed: could not reach ${publicUrl} (${detail}).`,
664
+ };
665
+ }
666
+ finally {
667
+ clearTimeout(timeout);
668
+ }
669
+ }
670
+ }
671
+ /**
672
+ * Create and configure a call server
673
+ */
674
+ export function createCallServer(config, port, publicUrl) {
675
+ return new CallServer({
676
+ port,
677
+ publicUrl,
678
+ config,
679
+ });
680
+ }
681
+ //# sourceMappingURL=call-server.js.map